Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 12 additions & 4 deletions packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import type {
import type { SSRActions, SSRManifestCSP, SSRManifest, SSRManifestI18n } from '../app/types.js';
import {
getAlgorithm,
getScriptHashes,
getStyleHashes,
shouldTrackCspHashes,
trackScriptHashes,
trackStyleHashes,
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 12 additions & 4 deletions packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import type {
} from '../../app/types.js';
import {
getAlgorithm,
getScriptHashes,
getStyleHashes,
shouldTrackCspHashes,
trackScriptHashes,
trackStyleHashes,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/core/config/schemas/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
32 changes: 32 additions & 0 deletions packages/astro/src/core/config/schemas/refined.ts
Original file line number Diff line number Diff line change
@@ -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<AstroConfig>().superRefine((config, ctx) => {
if (
Expand Down Expand Up @@ -203,4 +204,35 @@ export const AstroConfigRefinedSchema = z.custom<AstroConfig>().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'],
});
}
}
}
}
}
});
37 changes: 23 additions & 14 deletions packages/astro/src/core/csp/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AstroConfig['experimental']['csp'], false>;

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(
Expand Down
29 changes: 26 additions & 3 deletions packages/astro/src/core/csp/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import { z } from 'zod';

type UnionToIntersection<U> = (U extends never ? never : (arg: U) => never) extends (
arg: infer I,
) => void
? I
: never;

type UnionToTuple<T> = UnionToIntersection<T extends never ? never : (t: T) => T> extends (
_: never,
) => infer W
? [...UnionToTuple<Exclude<T, W>>, 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<CspAlgorithmValue>;

export const cspAlgorithmSchema = z
.enum(['SHA-512', 'SHA-384', 'SHA-256'])
.enum(Object.keys(ALGORITHMS) as UnionToTuple<CspAlgorithm>)
.optional()
.default('SHA-256');

export type CspAlgorithm = z.infer<typeof cspAlgorithmSchema>;
32 changes: 30 additions & 2 deletions packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })[];

Expand Down Expand Up @@ -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
Expand All @@ -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}`[];
};

/**
Expand Down
15 changes: 10 additions & 5 deletions packages/astro/src/vite-plugin-astro-server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
};
}

Expand Down
34 changes: 34 additions & 0 deletions packages/astro/test/csp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
});
});
23 changes: 23 additions & 0 deletions packages/astro/test/types/define-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
},
},
});
});
});
36 changes: 36 additions & 0 deletions packages/astro/test/units/config/config-validate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
});
Loading