Skip to content
Open
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
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` property. Depending on whether you use `scriptDirective` or `styleDirective`, `"element"` targets `script-src-elem` or `style-src-elem`, `"attribute"` targets `script-src-attr` or `style-src-attr`, and `"default"` (the same as a bare string or hash) targets `script-src` or `style-src`.

```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' });
```
38 changes: 36 additions & 2 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,49 @@ 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;
directives: CspDirective[];
/**
* @deprecated Use {@linkcode scriptDirective} instead. Holds the `default`-kind `script-src`
* hashes (the same values `scriptDirective.hashes` carries with `kind: "default"`).
*/
scriptHashes: string[];
/**
* @deprecated Use {@linkcode scriptDirective} instead. Holds the `default`-kind `script-src`
* resources.
*/
scriptResources: string[];
/**
* @deprecated Use {@linkcode scriptDirective}'s `strictDynamic` instead.
*/
isStrictDynamic: boolean;
/**
* @deprecated Use {@linkcode styleDirective} instead. Holds the `default`-kind `style-src`
* hashes.
*/
styleHashes: string[];
/**
* @deprecated Use {@linkcode styleDirective} instead. Holds the `default`-kind `style-src`
* resources.
*/
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
36 changes: 28 additions & 8 deletions packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
trackScriptHashes,
trackStyleHashes,
} from '../../csp/common.js';
import { partitionByKind } from '../../csp/runtime.js';
import { encodeKey } from '../../encryption.js';
import { fileExtension, joinPaths, prependForwardSlash } from '../../path.js';
import { DEFAULT_COMPONENTS } from '../../routing/default.js';
Expand Down Expand Up @@ -308,26 +309,45 @@ 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)),
];

const scriptDirective = {
resources: getScriptResources(cspConfig),
hashes: scriptHashes,
strictDynamic: getStrictDynamic(cspConfig),
};
const styleDirective = {
resources: getStyleResources(cspConfig),
hashes: styleHashes,
};
// Derive the deprecated flat fields from the `default`-kind entries for back-compat.
const scriptDefault = partitionByKind(scriptDirective).default;
const styleDefault = partitionByKind(styleDirective).default;

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),
scriptHashes: scriptDefault.hashes,
scriptResources: scriptDefault.resources,
isStrictDynamic: scriptDirective.strictDynamic,
styleHashes: styleDefault.hashes,
styleResources: styleDefault.resources,
scriptDirective,
styleDirective,
};
}

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
Loading
Loading