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
5 changes: 5 additions & 0 deletions .changeset/short-animals-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Adds `styleDirective.unsafeInline` option to CSP configuration. When set to `true`, Astro will emit `'unsafe-inline'` in the `style-src` directive and skip emitting style hashes, allowing inline styles to work. This is an opt-in escape hatch for projects that need `unsafe-inline` for styles while still benefiting from Astro's script hashing.
1 change: 1 addition & 0 deletions packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export type SSRManifestCSP = {
isStrictDynamic: boolean;
styleHashes: string[];
styleResources: string[];
isStyleUnsafeInline: boolean;
directives: CspDirective[];
};

Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getStrictDynamic,
getStyleHashes,
getStyleResources,
getStyleUnsafeInline,
shouldTrackCspHashes,
trackScriptHashes,
trackStyleHashes,
Expand Down Expand Up @@ -328,6 +329,7 @@ async function buildManifest(
algorithm,
directives: getDirectives(settings),
isStrictDynamic: getStrictDynamic(settings.config.security.csp),
isStyleUnsafeInline: getStyleUnsafeInline(settings.config.security.csp),
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/config/schemas/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ export const AstroConfigSchema = z.object({
.object({
resources: z.array(z.string()).optional(),
hashes: z.array(cspHashSchema).optional(),
unsafeInline: z.boolean().optional(),
})
.optional(),
scriptDirective: z
Expand Down
7 changes: 7 additions & 0 deletions packages/astro/src/core/csp/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ export function getStrictDynamic(csp: EnabledCsp): boolean {
return csp.scriptDirective?.strictDynamic ?? false;
}

export function getStyleUnsafeInline(csp: EnabledCsp): boolean {
if (csp === true) {
return false;
}
return csp.styleDirective?.unsafeInline ?? false;
}

export async function trackStyleHashes(
internals: BuildInternals,
settings: AstroSettings,
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/fetch/fetch-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ export class FetchState implements AstroFetchState {
styleResources: manifest.csp?.styleResources ? [...manifest.csp.styleResources] : [],
directives: manifest.csp?.directives ? [...manifest.csp.directives] : [],
isStrictDynamic: manifest.csp?.isStrictDynamic ?? false,
isStyleUnsafeInline: manifest.csp?.isStyleUnsafeInline ?? false,
internalFetchHeaders: manifest.internalFetchHeaders,
};

Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/manifest/serialized.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getStrictDynamic,
getStyleHashes,
getStyleResources,
getStyleUnsafeInline,
shouldTrackCspHashes,
} from '../core/csp/common.js';
import { createKey, encodeKey, getEnvironmentKey, hasEnvironmentKey } from '../core/encryption.js';
Expand Down Expand Up @@ -169,6 +170,7 @@ async function createSerializedManifest(
algorithm: getAlgorithm(settings.config.security.csp),
directives: getDirectives(settings),
isStrictDynamic: getStrictDynamic(settings.config.security.csp),
isStyleUnsafeInline: getStyleUnsafeInline(settings.config.security.csp),
};
}

Expand Down
9 changes: 8 additions & 1 deletion packages/astro/src/runtime/server/render/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export function renderCspContent(result: SSRResult): string {

const strictDynamic = result.isStrictDynamic ? ` 'strict-dynamic'` : '';
const scriptSrc = `script-src ${scriptResources} ${Array.from(finalScriptHashes).join(' ')}${strictDynamic};`;
const styleSrc = `style-src ${styleResources} ${Array.from(finalStyleHashes).join(' ')};`;

// When unsafeInline is enabled for styles, skip emitting style hashes (per the CSP spec,
// browsers ignore 'unsafe-inline' when hashes or nonces are present in the same directive).
// Instead, emit 'unsafe-inline' explicitly so it remains effective.
const styleSrc = result.isStyleUnsafeInline
? `style-src ${styleResources} 'unsafe-inline';`
: `style-src ${styleResources} ${Array.from(finalStyleHashes).join(' ')};`;

return [directives, scriptSrc, styleSrc].filter(Boolean).join(' ');
}
33 changes: 31 additions & 2 deletions packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export type { RemotePattern };

export type { SvgOptimizer };

export type CspStyleDirective = { hashes?: CspHash[]; resources?: string[] };
export type CspStyleDirective = { hashes?: CspHash[]; resources?: string[]; unsafeInline?: boolean };
export type CspScriptDirective = {
hashes?: CspHash[];
resources?: string[];
Expand Down Expand Up @@ -734,7 +734,7 @@ export interface AstroUserConfig<
* - External scripts and external styles are not supported out of the box, but you can [provide your own hashes](https://docs.astro.build/en/reference/configuration-reference/#securitycspscriptdirectivehashes).
* - [Astro's view transitions](https://docs.astro.build/en/guides/view-transitions/) using the `<ClientRouter />` are not supported, but you can [consider migrating to the browser native View Transition API](https://events-3bg.pages.dev/jotter/astro-view-transitions/) instead if you are not using Astro's enhancements to the native View Transitions and Navigation APIs.
* - Shiki isn't currently supported. By design, Shiki functions use inline styles that cannot work with Astro CSP implementation. Consider [using `<Prism />`](https://docs.astro.build/en/guides/syntax-highlighting/#prism-) when your project requires both CSP and syntax highlighting.
* - `unsafe-inline` directives are incompatible with Astro's CSP implementation. By default, Astro will emit hashes for all its bundled scripts (e.g. client islands) and all modern browsers will automatically reject `unsafe-inline` when it occurs in a directive with a hash or nonce.
* - `unsafe-inline` directives are incompatible with Astro's default CSP implementation. By default, Astro will emit hashes for all its bundled scripts (e.g. client islands) and all modern browsers will automatically reject `unsafe-inline` when it occurs in a directive with a hash or nonce. You can opt in to `unsafe-inline` for styles specifically using [`styleDirective.unsafeInline`](https://docs.astro.build/en/reference/configuration-reference/#securitycspstyledirectiveunsafeinline).
*
* :::note
* Due to the nature of the Vite dev server, this feature isn't supported while working in `dev` mode. Instead, you can test this in your Astro project using `build` and `preview`.
Expand Down Expand Up @@ -926,6 +926,35 @@ export interface AstroUserConfig<
* When resources are inserted multiple times or from multiple sources (e.g. defined in your `csp` config and added using [the CSP runtime API](/en/reference/api-reference/#csp)), Astro will merge and deduplicate all resources to create your `<meta>` element.
*/
resources?: string[];

/**
* @docs
* @name security.csp.styleDirective.unsafeInline
* @kind h6
* @type {boolean}
* @default `false`
* @version 6.3.7
* @description
*
* When set to `true`, Astro will emit `'unsafe-inline'` in the `style-src` directive and skip emitting style hashes. This allows inline styles to work, but note that `'unsafe-inline'` is ignored by browsers when hashes or nonces are present in the same directive, so this option also disables style hash generation.
*
* This is an opt-in escape hatch for projects that need `unsafe-inline` for styles while still benefiting from Astro's script hashing.
*
* ```js title="astro.config.mjs"
* import { defineConfig } from 'astro/config';
*
* export default defineConfig({
* security: {
* csp: {
* styleDirective: {
* unsafeInline: true,
* }
* }
* }
* });
* ```
*/
unsafeInline?: boolean;
};

/**
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/types/public/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ export interface SSRResult {
styleResources: SSRManifestCSP['styleResources'];
directives: SSRManifestCSP['directives'];
isStrictDynamic: SSRManifestCSP['isStrictDynamic'];
isStyleUnsafeInline: SSRManifestCSP['isStyleUnsafeInline'];
internalFetchHeaders?: Record<string, string>;

/**
Expand Down
12 changes: 12 additions & 0 deletions packages/astro/test/types/define-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ describe('defineConfig()', () => {
});
});

it('Validates CSP styleDirective.unsafeInline', () => {
defineConfig({
security: {
csp: {
styleDirective: {
unsafeInline: true,
},
},
},
});
});

it('Allows session config without a driver', () => {
// Adapters like Cloudflare, Netlify, and Node provide default session drivers,
// so users should be able to configure session options without specifying a driver
Expand Down
58 changes: 58 additions & 0 deletions packages/astro/test/units/csp/rendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function createCspPipeline(cspConfig: Partial<SSRManifestCSP> = {}): Pipeline {
styleResources: cspConfig.styleResources || [],
directives: cspConfig.directives || [],
isStrictDynamic: cspConfig.isStrictDynamic || false,
isStyleUnsafeInline: cspConfig.isStyleUnsafeInline || false,
},
},
writable: false,
Expand Down Expand Up @@ -418,6 +419,63 @@ describe('CSP Rendering', () => {
});
});

describe('Style Unsafe Inline', () => {
it("should emit 'unsafe-inline' and omit style hashes when unsafeInline is enabled", async () => {
const pipeline = createCspPipeline({
isStyleUnsafeInline: true,
styleHashes: ['sha256-abc123'],
styleResources: ["'self'", 'https://fonts.googleapis.com'],
});

const { html } = await renderPage(SimplePage, pipeline);
const $ = cheerio.load(html);

const meta = $('meta[http-equiv="Content-Security-Policy"]');
const content = meta.attr('content')!;

assert.ok(content.includes("'unsafe-inline'"), "Should include 'unsafe-inline'");
assert.ok(!content.includes('sha256-abc123'), 'Should not include style hashes');
assert.ok(content.includes('style-src'), 'Should have style-src directive');
assert.ok(
content.includes('https://fonts.googleapis.com'),
'Should still include style resources',
);
});

it('should not affect script hashes when unsafeInline is enabled for styles', async () => {
const pipeline = createCspPipeline({
isStyleUnsafeInline: true,
scriptHashes: ['sha256-scriptHash'],
styleHashes: ['sha256-styleHash'],
});

const { html } = await renderPage(SimplePage, pipeline);
const $ = cheerio.load(html);

const meta = $('meta[http-equiv="Content-Security-Policy"]');
const content = meta.attr('content')!;

assert.ok(content.includes('sha256-scriptHash'), 'Should still include script hashes');
assert.ok(!content.includes('sha256-styleHash'), 'Should not include style hashes');
assert.ok(content.includes("'unsafe-inline'"), "Should include 'unsafe-inline' in style-src");
});

it('should emit style hashes normally when unsafeInline is not set', async () => {
const pipeline = createCspPipeline({
styleHashes: ['sha256-abc123'],
});

const { html } = await renderPage(SimplePage, pipeline);
const $ = cheerio.load(html);

const meta = $('meta[http-equiv="Content-Security-Policy"]');
const content = meta.attr('content')!;

assert.ok(content.includes('sha256-abc123'), 'Should include style hash');
assert.ok(!content.includes("'unsafe-inline'"), "Should not include 'unsafe-inline'");
});
});

describe('CSP Content Parsing', () => {
it('should generate well-formed CSP content', async () => {
const pipeline = createCspPipeline({
Expand Down
1 change: 1 addition & 0 deletions packages/astro/test/units/render/context-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ describe('Astro.csp getter', () => {
styleResources: [],
directives: [],
isStrictDynamic: false,
isStyleUnsafeInline: false,
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ async function createStubResult(overrides: Partial<SSRResult> = {}): Promise<SSR
styleResources: [],
directives: [],
isStrictDynamic: false,
isStyleUnsafeInline: false,
internalFetchHeaders: {},
...overrides,
};
Expand Down
Loading