Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ export type SSRManifest = {
* When enabled, Astro tracks the hashes of script and styles, and eventually it will render the `<meta>` tag
*/
shouldInjectCspMetaTags: boolean;
astroIslandHashes: string[];
astroIslandHashes: AstroIslandHashes[];
};

export type AstroIslandHashes = {
name: string;
hash: string;
};

export type SSRActions = {
Expand Down
40 changes: 32 additions & 8 deletions packages/astro/src/core/astro-islands-hashes.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,35 @@
// This file is code-generated, please don't change it manually
export const ASTRO_ISLAND_HASHES = [
"GI/D8grziRZwfj/Mqmn+dcgU/i8sylHSR/IfobqcUT4=",
"HDWxd14AUw8OvjrhhRRyyZFHCGnzxXGDrg59Qi8ayhc=",
"XN6a2Vn8uvpBr/WhdYPdK0jVeCzlcOD2XYaP10veV4Y=",
"ZR0ZAU8UNTzLmo/ApeWH0y1mVLT+XtFkvZ5nw32W8jI=",
"cSNmhdbFlyTDRozeu9HPjo+B2S4QAeMp0RO41PqgAcA=",
"mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=",
"mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=",
"s81ZcLcyAa7P/Jh5M5hUxYthTGwW+iZY3e6aHrQ8H9E="
{
"hash": "mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=",
"name": "astro-island"
},
{
"hash": "mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=",
"name": "astro-island"
},
{
"hash": "GI/D8grziRZwfj/Mqmn+dcgU/i8sylHSR/IfobqcUT4=",
"name": "idle"
},
{
"hash": "XN6a2Vn8uvpBr/WhdYPdK0jVeCzlcOD2XYaP10veV4Y=",
"name": "load"
},
{
"hash": "HDWxd14AUw8OvjrhhRRyyZFHCGnzxXGDrg59Qi8ayhc=",
"name": "media"
},
{
"hash": "ZR0ZAU8UNTzLmo/ApeWH0y1mVLT+XtFkvZ5nw32W8jI=",
"name": "only"
},
{
"hash": "cSNmhdbFlyTDRozeu9HPjo+B2S4QAeMp0RO41PqgAcA=",
"name": "visible"
},
{
"hash": "s81ZcLcyAa7P/Jh5M5hUxYthTGwW+iZY3e6aHrQ8H9E=",
"name": "astro-island-styles"
}
];
12 changes: 9 additions & 3 deletions packages/astro/src/core/base-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { NOOP_MIDDLEWARE_FN } from './middleware/noop-middleware.js';
import { sequence } from './middleware/sequence.js';
import { RouteCache } from './render/route-cache.js';
import { createDefaultRoutes } from './routing/default.js';
import { createCSPMiddleware } from './csp/middleware.js';

/**
* The `Pipeline` represents the static parts of rendering that do not change between requests.
Expand Down Expand Up @@ -112,11 +113,16 @@ export abstract class Pipeline {
else if (this.middleware) {
const middlewareInstance = await this.middleware();
const onRequest = middlewareInstance.onRequest ?? NOOP_MIDDLEWARE_FN;
const internalMiddlewares = [onRequest];
if (this.manifest.checkOrigin) {
this.resolvedMiddleware = sequence(createOriginCheckMiddleware(), onRequest);
} else {
this.resolvedMiddleware = onRequest;
// 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 {
this.resolvedMiddleware = NOOP_MIDDLEWARE_FN;
Expand Down
41 changes: 35 additions & 6 deletions packages/astro/src/core/csp/common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AstroConfig } from '../../types/public/index.js';
import type { AstroConfig, SSRResult } from '../../types/public/index.js';
import type { BuildInternals } from '../build/internal.js';
import crypto from 'node:crypto';
import type { AstroSettings } from '../../types/astro.js';
Expand All @@ -12,9 +12,7 @@ export function trackStyleHashes(internals: BuildInternals): 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'),
);
clientStyleHashes.push(generateHash(style.sheet.content));
}
}
}
Expand All @@ -26,15 +24,46 @@ export function trackScriptHashes(internals: BuildInternals, settings: AstroSett
const clientScriptHashes: string[] = [];

for (const script of internals.inlinedScripts.values()) {
clientScriptHashes.push(crypto.createHash('sha256').update(script).digest('base64'));
clientScriptHashes.push(generateHash(script));
}

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'));
clientScriptHashes.push(generateHash(content));
}
}

return clientScriptHashes;
}

function generateHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('base64');
}

export function renderCspContent(result: SSRResult): string {
const finalScriptHashes = new Set();
const finalStyleHashes = new Set();

for (const scriptHash of result.clientScriptHashes) {
finalScriptHashes.add(`'sha256-${scriptHash}'`);
}

for (const styleHash of result.clientStyleHashes) {
finalStyleHashes.add(`'sha256-${styleHash}'`);
}

if (result.renderers.length > 0) {
for (const { name, hash } of result.astroIslandHashes) {
if (name === 'astro-island-styles') {
finalStyleHashes.add(`'sha256-${hash}'`);
} else {
finalScriptHashes.add(`'sha256-${hash}'`);
}
}
}

const scriptSrc = `style-src 'self' ${Array.from(finalStyleHashes).join(' ')};`;
const styleSrc = `script-src 'self' ${Array.from(finalScriptHashes).join(' ')};`;
return `${scriptSrc} ${styleSrc}`;
}
11 changes: 11 additions & 0 deletions packages/astro/src/core/csp/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
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;
};
}
1 change: 1 addition & 0 deletions packages/astro/src/core/render-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ export class RenderContext {
shouldInjectCspMetaTags: manifest.shouldInjectCspMetaTags,
clientScriptHashes: manifest.clientScriptHashes,
clientStyleHashes: manifest.clientStyleHashes,
astroIslandHashes: manifest.astroIslandHashes,
};

return result;
Expand Down
13 changes: 5 additions & 8 deletions packages/astro/src/runtime/server/render/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { SSRResult } from '../../../types/public/internal.js';
import type { HTMLBytes, HTMLString } from '../escape.js';
import { markHTMLString } from '../escape.js';
import {
type PrescriptType,
determineIfNeedsHydrationScript,
determinesIfNeedsDirectiveScript,
getPrescripts,
Expand Down Expand Up @@ -65,13 +64,11 @@ function stringifyChunk(
let needsDirectiveScript =
hydration && determinesIfNeedsDirectiveScript(result, hydration.directive);

let prescriptType: PrescriptType = needsHydrationScript
? 'both'
: needsDirectiveScript
? 'directive'
: null;
if (prescriptType) {
let prescripts = getPrescripts(result, prescriptType, hydration.directive);
if (needsHydrationScript) {
let prescripts = getPrescripts(result, 'both', hydration.directive);
return markHTMLString(prescripts);
} else if (needsDirectiveScript) {
let prescripts = getPrescripts(result, 'directive', hydration.directive);
return markHTMLString(prescripts);
} else {
return '';
Expand Down
30 changes: 12 additions & 18 deletions packages/astro/src/runtime/server/render/head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { markHTMLString } from '../escape.js';
import type { MaybeRenderHeadInstruction, RenderHeadInstruction } from './instruction.js';
import { createRenderInstruction } from './instruction.js';
import { renderElement } from './util.js';
import { renderCspContent } from '../../../core/csp/common.js';

// Filter out duplicate elements in our set
const uniqueElements = (item: any, index: number, all: any[]) => {
Expand Down Expand Up @@ -51,26 +52,19 @@ 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 += renderElement(
'meta',
{
props: {
'http-equiv': 'content-security-policy',
content: renderCspContent(result),
},
children: '',
},
false,
);
}
content += hashes.join('\n');

return markHTMLString(content);
}
Expand Down
9 changes: 3 additions & 6 deletions packages/astro/src/runtime/server/scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function determinesIfNeedsDirectiveScript(result: SSRResult, directive: s
return true;
}

export type PrescriptType = null | 'both' | 'directive';
export type PrescriptType = 'both' | 'directive';

function getDirectiveScriptText(result: SSRResult, directive: string): string {
const clientDirectives = result.clientDirectives;
Expand All @@ -31,8 +31,8 @@ function getDirectiveScriptText(result: SSRResult, directive: string): string {

export function getPrescripts(result: SSRResult, type: PrescriptType, directive: string): string {
// Note that this is a classic script, not a module script.
// This is so that it executes immediate, and when the browser encounters
// an astro-island element the callbacks will fire immediately, causing the JS
// This is so that it executes immediately, and when the browser encounters
// an astro-island element, the callbacks will fire immediately, causing the JS
// deps to be loaded immediately.
switch (type) {
case 'both':
Expand All @@ -41,8 +41,5 @@ export function getPrescripts(result: SSRResult, type: PrescriptType, directive:
}</script>`;
case 'directive':
return `<script>${getDirectiveScriptText(result, directive)}</script>`;
case null:
break;
}
return '';
}
6 changes: 4 additions & 2 deletions packages/astro/src/types/public/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Params } from './common.js';
import type { AstroConfig, RedirectConfig } from './config.js';
import type { AstroGlobal, AstroGlobalPartial } from './context.js';
import type { AstroRenderer } from './integrations.js';
import type { SSRManifest } from '../../core/app/types.js';

export type { SSRManifest } from '../../core/app/types.js';

Expand Down Expand Up @@ -250,8 +251,9 @@ export interface SSRResult {
* Whether Astro should inject the CSP <meta> tag into the head of the component.
*/
shouldInjectCspMetaTags: boolean;
clientScriptHashes: string[];
clientStyleHashes: string[];
clientScriptHashes: SSRManifest['clientScriptHashes'];
clientStyleHashes: SSRManifest['clientStyleHashes'];
astroIslandHashes: SSRManifest['astroIslandHashes'];
}

/**
Expand Down
26 changes: 24 additions & 2 deletions packages/astro/test/csp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,32 @@ describe('CSP', () => {
const response = await app.render(request);
const $ = cheerio.load(await response.text());

const meta = $('meta[http-equiv="Content-Security-Policy"]');
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}`);
assert.match(
meta.attr('content'),
new RegExp(`'sha256-${hash}'`),
`Should have a CSP meta tag for ${hash}`,
);
}

let astroStyleHash = manifest.astroIslandHashes.find(
({ name }) => name === 'astro-island-styles',
);
astroStyleHash = `sha256-${astroStyleHash.hash}`;

let astroIsland = manifest.astroIslandHashes.find(({ name }) => name === 'astro-island');
astroIsland = `sha256-${astroIsland.hash}`;

assert.ok(
meta.attr('content').includes(astroStyleHash),
`Should have a CSP meta tag for ${astroStyleHash}`,
);

assert.ok(
meta.attr('content').includes(astroIsland),
`Should have a CSP meta tag for ${astroIsland}`,
);
} else {
assert.fail('Should have the manifest');
}
Expand Down
6 changes: 5 additions & 1 deletion packages/astro/test/fixtures/csp/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';

export default defineConfig({
experimental: {
csp: true,
}
},
integrations: [
react()
],
});

5 changes: 4 additions & 1 deletion packages/astro/test/fixtures/csp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
"astro": "workspace:*",
"@astrojs/react": "workspace:*",
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
}
5 changes: 5 additions & 0 deletions packages/astro/test/fixtures/csp/src/components/Text.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@


export function Text() {
return "Text"
}
18 changes: 18 additions & 0 deletions packages/astro/test/fixtures/csp/src/pages/react.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
import {Text} from "../components/Text.jsx"
---


<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width"/>
<title>Index</title>
</head>
<body>
<main>
<h1>React</h1>
<Text client:load />
</main>
</body>
</html>
Loading
Loading