Skip to content
Open
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
38 changes: 38 additions & 0 deletions .changeset/lovely-nights-stay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'astro': minor
---

Adds support for the more specific CSP directives `script-src-elem`, `script-src-attr`, `style-src-elem`, and `style-src-attr` through a new `kind` option.

Previously, [`CSP`](https://docs.astro.build/en/reference/configuration-reference/#securitycsp) was only scoped to generic `script-src`/`style-src` directives. Now each source or hash can be scoped to a narrower directive — for example, to allow inline `style` attributes (such as those from `define:vars` or Shiki) without loosening the policy for your `<style>` and `<link>` elements.

#### Scoping sources and hashes in your config

Each entry in `resources` and `hashes` can be an object with a `kind`: `"element"` targets `script-src-elem`/`style-src-elem`, `"attribute"` targets `script-src-attr`/`style-src-attr`, and `"default"` (the same as a bare string or hash) targets `script-src`/`style-src`.
Comment thread
ematipico marked this conversation as resolved.
Outdated

```js
// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
security: {
csp: {
scriptDirective: {
resources: [{ resource: 'https://cdn.example.com', kind: 'element' }],
},
styleDirective: {
resources: [{ resource: "'unsafe-inline'", kind: 'attribute' }],
},
},
},
});
```

#### Scoping at runtime

The same `kind` option is available on the runtime CSP API, where the existing methods now also accept an object:

```js
ctx.csp.insertScriptResource({ resource: 'https://cdn.example.com', kind: 'element' });
ctx.csp.insertStyleResource({ resource: "'unsafe-inline'", kind: 'attribute' });
```
4 changes: 2 additions & 2 deletions packages/astro/src/assets/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ export function createSvgComponent({ meta, attributes, children, styles }: SvgCo
// The styles stay inside the <svg> where they belong — we only need the
// hashes registered before the CSP meta tag is emitted in the <head>.
// propagation: 'self' ensures init() runs during bufferHeadContent().
if (hasStyles && result.cspDestination) {
if (hasStyles && result.csp.cspDestination) {
for (const style of styles) {
const hash = await generateCspDigest(style, result.cspAlgorithm);
const hash = await generateCspDigest(style, result.csp.algorithm);
result._metadata.extraStyleHashes.push(hash);
}
}
Expand Down
22 changes: 16 additions & 6 deletions packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
SSRResult,
} from '../../types/public/internal.js';
import type { SinglePageBuiltModule } from '../build/types.js';
import type { CspDirective } from '../csp/config.js';
import type { CspDirective, CspHashEntry, CspResourceEntry } from '../csp/config.js';
import type {
AstroLoggerDestination,
AstroLoggerLevel,
Expand Down Expand Up @@ -170,15 +170,25 @@ export type SSRManifestI18n = {
domains: Record<string, string> | undefined;
};

/**
* The CSP section of the manifest. It mirrors the `security.csp` config: `directives` plus a
* `scriptDirective`/`styleDirective`, each holding `resources`/`hashes` entries that carry their
* `kind` (`default`/`element`/`attribute`). The `kind` is only interpreted at render time. Astro's
* generated hashes are appended to the relevant `hashes` array as `default`-kind entries.
*/
export type SSRManifestCSP = {
cspDestination: 'adapter' | 'meta' | 'header' | undefined;
algorithm: CspAlgorithm;
scriptHashes: string[];
scriptResources: string[];
isStrictDynamic: boolean;
styleHashes: string[];
styleResources: string[];
directives: CspDirective[];
scriptDirective: {
resources: CspResourceEntry[];
hashes: CspHashEntry[];
strictDynamic: boolean;
};
styleDirective: {
resources: CspResourceEntry[];
hashes: CspHashEntry[];
};
};

export interface SSRManifestSession extends BaseSessionConfig {
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/core/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { collectPagesData } from './page-data.js';
import { viteBuild } from './static-build.js';
import type { StaticBuildOptions } from './types.js';
import { getTimeStat } from './util.js';
import { warnIfCspWithShiki } from '../messages/runtime.js';
import { warnIfCspResourceFallbackShadowing, warnIfCspWithShiki } from '../messages/runtime.js';

interface BuildOptions {
/**
Expand Down Expand Up @@ -57,6 +57,7 @@ export default async function build(
telemetry.record(eventCliSession('build', userConfig));

warnIfCspWithShiki(astroConfig, logger);
warnIfCspResourceFallbackShadowing(astroConfig, logger);

const settings = await createSettings(
astroConfig,
Expand Down
24 changes: 16 additions & 8 deletions packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,26 +308,34 @@ async function buildManifest(
let csp: SSRManifestCSP | undefined = undefined;

if (shouldTrackCspHashes(settings.config.security.csp)) {
const algorithm = getAlgorithm(settings.config.security.csp);
const cspConfig = settings.config.security.csp;
const algorithm = getAlgorithm(cspConfig);
// Astro's generated hashes are element hashes. They are appended to the directive's `hashes`
// as `default`-kind entries (bare strings), so they land on `script-src`/`style-src` and are
// folded into the `-elem` directives at render time.
const scriptHashes = [
...getScriptHashes(settings.config.security.csp),
...getScriptHashes(cspConfig),
...(await trackScriptHashes(internals, settings, algorithm)),
];
const styleHashes = [
...getStyleHashes(settings.config.security.csp),
...getStyleHashes(cspConfig),
...settings.injectedCsp.styleHashes,
...(await trackStyleHashes(internals, settings, algorithm)),
];

csp = {
cspDestination: settings.adapter?.adapterFeatures?.staticHeaders ? 'adapter' : undefined,
scriptHashes,
scriptResources: getScriptResources(settings.config.security.csp),
styleHashes,
styleResources: getStyleResources(settings.config.security.csp),
algorithm,
directives: getDirectives(settings),
isStrictDynamic: getStrictDynamic(settings.config.security.csp),
scriptDirective: {
resources: getScriptResources(cspConfig),
hashes: scriptHashes,
strictDynamic: getStrictDynamic(cspConfig),
},
styleDirective: {
resources: getStyleResources(cspConfig),
hashes: styleHashes,
},
};
}

Expand Down
15 changes: 10 additions & 5 deletions packages/astro/src/core/config/schemas/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ import { SvgOptimizerSchema } from '../../../assets/svg/config.js';
import { EnvSchema } from '../../../env/schema.js';
import type { AstroUserConfig, ViteUserConfig } from '../../../types/public/config.js';
import { CacheSchema, RouteRulesSchema } from '../../cache/config.js';
import { allowedDirectivesSchema, cspAlgorithmSchema, cspHashSchema } from '../../csp/config.js';
import {
allowedDirectivesSchema,
cspAlgorithmSchema,
cspHashEntrySchema,
cspResourceEntrySchema,
} from '../../csp/config.js';
import { SessionSchema } from '../../session/config.js';

// The below types are required boilerplate to work around a Zod issue since v3.21.2. Since that version,
Expand Down Expand Up @@ -521,14 +526,14 @@ export const AstroConfigSchema = z.object({
directives: z.array(allowedDirectivesSchema).optional(),
styleDirective: z
.object({
resources: z.array(z.string()).optional(),
hashes: z.array(cspHashSchema).optional(),
resources: z.array(cspResourceEntrySchema).optional(),
hashes: z.array(cspHashEntrySchema).optional(),
})
.optional(),
scriptDirective: z
.object({
resources: z.array(z.string()).optional(),
hashes: z.array(cspHashSchema).optional(),
resources: z.array(cspResourceEntrySchema).optional(),
hashes: z.array(cspHashEntrySchema).optional(),
strictDynamic: z.boolean().optional(),
})
.optional(),
Expand Down
27 changes: 13 additions & 14 deletions packages/astro/src/core/csp/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { AstroSettings } from '../../types/astro.js';
import type { AstroConfig, CspAlgorithm } from '../../types/public/index.js';
import type { BuildInternals } from '../build/internal.js';
import { generateCspDigest } from '../encryption.js';
import type { CspDirective } from './config.js';
import type { CspDirective, CspHash, CspHashEntry, CspResourceEntry } from './config.js';

type EnabledCsp = Exclude<AstroConfig['security']['csp'], false>;

Expand All @@ -22,33 +22,32 @@ export function getAlgorithm(csp: EnabledCsp): CspAlgorithm {
return csp.algorithm;
}

export function getScriptHashes(csp: EnabledCsp): string[] {
export function getScriptResources(csp: EnabledCsp): CspResourceEntry[] {
if (csp === true) {
return [];
} else {
return csp.scriptDirective?.hashes ?? [];
}
return csp.scriptDirective?.resources ?? [];
}

export function getScriptResources(csp: EnabledCsp): string[] {
export function getScriptHashes(csp: EnabledCsp): CspHashEntry[] {
if (csp === true) {
return [];
}
return csp.scriptDirective?.resources ?? [];
return csp.scriptDirective?.hashes ?? [];
}

export function getStyleHashes(csp: EnabledCsp): string[] {
export function getStyleResources(csp: EnabledCsp): CspResourceEntry[] {
if (csp === true) {
return [];
}
return csp.styleDirective?.hashes ?? [];
return csp.styleDirective?.resources ?? [];
}

export function getStyleResources(csp: EnabledCsp): string[] {
export function getStyleHashes(csp: EnabledCsp): CspHashEntry[] {
if (csp === true) {
return [];
}
return csp.styleDirective?.resources ?? [];
return csp.styleDirective?.hashes ?? [];
}

// Unlike other helpers like getStyleResources, getDirectives has more logic
Expand Down Expand Up @@ -97,8 +96,8 @@ export async function trackStyleHashes(
internals: BuildInternals,
settings: AstroSettings,
algorithm: CspAlgorithm,
): Promise<string[]> {
const clientStyleHashes: string[] = [];
): Promise<CspHash[]> {
const clientStyleHashes: CspHash[] = [];
for (const [_, page] of internals.pagesByViteID.entries()) {
for (const style of page.styles) {
if (style.sheet.type === 'inline') {
Expand Down Expand Up @@ -128,8 +127,8 @@ export async function trackScriptHashes(
internals: BuildInternals,
settings: AstroSettings,
algorithm: CspAlgorithm,
): Promise<string[]> {
const clientScriptHashes: string[] = [];
): Promise<CspHash[]> {
const clientScriptHashes: CspHash[] = [];

for (const script of internals.inlinedScripts.values()) {
clientScriptHashes.push(await generateCspDigest(script, algorithm));
Expand Down
72 changes: 71 additions & 1 deletion packages/astro/src/core/csp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,76 @@ export const cspHashSchema = z.custom<`${CspAlgorithmValue}${string}`>((value) =

export type CspHash = z.infer<typeof cspHashSchema>;

/**
* The scope a resource or hash applies to:
* - `default`: the generic `script-src`/`style-src` directive (default behavior).
* - `element`: the `script-src-elem`/`style-src-elem` directive (`<script>`, `<style>`, `<link rel="stylesheet">`).
* - `attribute`: the `script-src-attr`/`style-src-attr` directive (inline event handlers and inline `style` attributes).
*/
export const CSP_KINDS = ['element', 'attribute', 'default'] as const;
export type CspKind = (typeof CSP_KINDS)[number];

export const cspKindSchema = z.enum(CSP_KINDS);

/**
* A `script-src`/`style-src` resource. A bare string targets `script-src`/`style-src` (the same as
* `kind: 'default'`). Use the object form with an explicit `kind` to target a more specific directive.
*/
export type CspResourceEntry = string | { resource: string; kind: CspKind };

/**
* A `script-src`/`style-src` hash. A bare hash (a string) targets `script-src`/`style-src` (the same as
* `kind: 'default'`). Use the object form with an explicit `kind` to target a more specific directive.
*/
export type CspHashEntry = CspHash | { hash: CspHash; kind: CspKind };

// Per MDN, `script-src-attr`/`style-src-attr` only accept these keyword sources.
const ATTRIBUTE_ALLOWED_RESOURCES = [
"'none'",
"'unsafe-hashes'",
"'unsafe-inline'",
"'report-sample'",
] as const;

export const cspResourceEntrySchema = z
.union([
z.string(),
z.object({
resource: z.string(),
kind: cspKindSchema,
}),
])
.superRefine((value, ctx) => {
// `value` is already a valid union member here, so a bare string is the `default` kind.
const resource = typeof value === 'string' ? value : value.resource;
const kind = typeof value === 'string' ? 'default' : value.kind;
// `'unsafe-hashes'` is invalid on the `-elem` directives.
if (kind === 'element' && resource === "'unsafe-hashes'") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The source \`'unsafe-hashes'\` is not valid for \`element\` resources (it is rejected by \`script-src-elem\`/\`style-src-elem\`).`,
fatal: true,
});
} else if (
kind === 'attribute' &&
!(ATTRIBUTE_ALLOWED_RESOURCES as readonly string[]).includes(resource)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The source \`${resource}\` is not valid for \`attribute\` resources. \`script-src-attr\`/\`style-src-attr\` only accept: ${ATTRIBUTE_ALLOWED_RESOURCES.join(', ')}.`,
fatal: true,
});
}
});

export const cspHashEntrySchema = z.union([
cspHashSchema,
z.object({
hash: cspHashSchema,
kind: cspKindSchema,
}),
]);

const ALLOWED_DIRECTIVES = [
'base-uri',
'child-src',
Expand Down Expand Up @@ -76,7 +146,7 @@ export const allowedDirectivesSchema = z
if (value.startsWith('script-src') || value.startsWith('style-src')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Directives \`script-src\` and \`style-src\` are not allowed in \`security.csp.directives\`. Please use \`security.csp.scriptDirective\` and \`security.csp.styleDirective\` instead.`,
message: `Directives \`script-src\` and \`style-src\` (including their \`-elem\`/\`-attr\` variants) are not allowed in \`security.csp.directives\`. Please use \`security.csp.scriptDirective\` and \`security.csp.styleDirective\` instead, scoping resources/hashes to the more specific directives with the \`kind\` option (\`"element"\` or \`"attribute"\`).`,
fatal: true,
});
} else {
Expand Down
45 changes: 44 additions & 1 deletion packages/astro/src/core/csp/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,48 @@
import type { SSRManifestCSP } from '../app/types.js';
import type { CspDirective } from './config.js';
import type { CspDirective, CspHash, CspHashEntry, CspKind, CspResourceEntry } from './config.js';

export function normalizeCspResourceEntry(entry: CspResourceEntry): {
resource: string;
kind: CspKind;
} {
if (typeof entry === 'string') {
return { resource: entry, kind: 'default' };
}
return { resource: entry.resource, kind: entry.kind ?? 'default' };
}

export function normalizeCspHashEntry(entry: CspHashEntry): { hash: CspHash; kind: CspKind } {
if (typeof entry === 'string') {
return { hash: entry, kind: 'default' };
}
return { hash: entry.hash, kind: entry.kind ?? 'default' };
}

/** The resolved sources of a single CSP directive. */
export type CspDirectiveSources = { resources: string[]; hashes: string[] };

/**
* Groups a directive's `resources`/`hashes` entries by their `kind`.
*/
export function partitionByKind(directive: {
resources: CspResourceEntry[];
hashes: CspHashEntry[];
}): Record<CspKind, CspDirectiveSources> {
const groups: Record<CspKind, CspDirectiveSources> = {
default: { resources: [], hashes: [] },
element: { resources: [], hashes: [] },
attribute: { resources: [], hashes: [] },
};
for (const entry of directive.resources) {
const { resource, kind } = normalizeCspResourceEntry(entry);
groups[kind].resources.push(resource);
}
for (const entry of directive.hashes) {
const { hash, kind } = normalizeCspHashEntry(entry);
groups[kind].hashes.push(hash);
}
return groups;
}

/**
* `existingDirective` is something like `img-src 'self'`. Same as `newDirective`.
Expand Down
Loading
Loading