Skip to content
Open
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
251 changes: 251 additions & 0 deletions proposals/0055-csp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
<!--
Note: You are probably looking for `stage-1--discussion-template.md`!
This template is reserved for anyone championing an already-approved proposal.

Community members who would like to propose an idea or feature should begin
by creating a GitHub Discussion. See the repo README.md for more info.

To use this template: create a new, empty file in the repo under `proposals/${ID}.md`.
Replace `${ID}` with the official accepted proposal ID, found in the GitHub Issue
of the accepted proposal.
-->

**If you have feedback and the feature is released as experimental, please leave it on the Stage 3 PR. Otherwise, comment on the Stage 2 issue (links below).**

- Start Date: 2025-05-13
- Reference Issues:
- Implementation PR:
- Stage 2 Issue: https://github.com/withastro/roadmap/issues/1149
- Stage 3 PR: https://github.com/withastro/roadmap/discussions/377

# Summary

Add a new feature that allows Astro assets, mainly scripts and styles, to support [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP).

# Example

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

export default defineConfig({
security: {
csp: true
}
})
```

# Background & Motivation

Include any useful background detail that that explains why this RFC is important.
What are the problems that this RFC sets out to solve? Why now? Be brief!

It can be useful to illustrate your RFC as a user problem in this section.
(ex: "Users have reported that it is difficult to do X in Astro today.")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is from the default template, we can probably extract things from the original discussion


# Goals

- Support at least one solution (`nonce`, hash, etc.) that would support CSP.
- Allow Astro scripts (client islands, server islands, view transitions, etc.) and Astro styles to work out of the box.
- Update adapters to support the feature, if needed.
- Make the feature opt-in, even when it's outside experimental.
- Support the [dev server](#dev-server) (stretch)
- View transitions (limited/opt-in support)

# Non-Goals

- Provide *multiple* solutions to fix the same problem
- Support for third-party scripts dynamically imported by users e.g. `document.createElement("script")`, `import("https://path.to/script.js")`;
- Support for third-party styles dynamically imported by users.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I undestand correctly, injectCspAsset would allow this

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to rephrase it then. What I meant is that out of the box Astro won't create a hash for the dynamically imported script/style. Any suggestions?

- Performance. Users and authors are willing to overlook possible performance drawbacks in order to increase security.


# Detailed Design

## Solution adopted

Contrary to other frameworks of the same ecosystem, our solution will rely on generating [**hashes of styles and scripts**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP#hashes). For more information, please refer to the [alternatives](#alternatives) section.

Astro provides the hashes during the rendering phase, they will be written in the `<head>` tag using the `<meta http-equiv="Content-Security-Policy">` meta tag.

The *main* reason for using this strategy instead of `Response` headers is because this solution allows Astro to support two important use cases:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting that using meta tags and not Response headers will limit some CDNs and WAFs ability to inject nonces that they inject in transit

Example: https://developers.cloudflare.com/cloudflare-challenges/challenge-types/javascript-detections/#if-you-have-a-content-security-policy-csp

JavaScript detections are not supported with nonce set via tags.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly I truly like to have this implemented.

It could be that easy as setting astro.locals.nonce =

I already have my middleware setting up the csp response headers.

For the astro implementation currently.
Than you can have both ways CSP through the current hashes implementation and use a nonce when astro.locals.nonce is available. The nonce can be injected for the scripts with/for the astro islands.

I already tried to make a work-around for the nonce for the astro islands, through the middleware to inject the nonce myself in the astro islands scripts.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's unsettling. Isn't it possible to use nonce and hashes at the same time?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I mean, you are truly doing a good job with the Astro out of the box solution.

However what I am telling I do not want to use the meta tags implementation because it is an issue, like @Johannes-Andersen mentioned.

I already have my CSP strict dynamic setup through middleware. Using astro.locals.nonce setup. I inject the nonce now in my scripts however than I cannot use typescript anymore very annoying but hey that's life :-)

Then I really wanted to use preact for the island behaviour and than I noticed that my CSP messed up life. So I start trying to make a work-around which is not working yet. I use output = server so my site is dynamic and I really don't want to loose performance. Calculating hashes doesn't work because I have no static pages. I used to have a blog the hashes integration only calculated them for the static pages. So I tried middleware to adjust the generated html and insert nonces but that is not working as it should be.

It would be nice to setup a nonce which is injected by astro itself on page generation. Or maybe someone can help me to make the solution.

But I really appreciate your efforts.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NepCono our implementation is current based on hashes, why do you want to use nonce instead?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the current hash implementation is through meta tags implementation, where somethings are not working.

Than I use a middleware implementation that I build myself to set to corresponding CSP response header.

With the current implementation of hashes being calculated everytime, it is far better and faster to use a nonce token. You only have to inject the nonce token in the scripts and your done (Astro.locals.nonce) when a user sets that problem solved. No hash calculation slowing things down, but hey that's only my opnion.

Also what @Johannes-Andersen is referring to.

In the mean time I am stuck, can you supply me with an astro integration that scans my pages (output server) so dynamic pages and or inject or hashes so I can move on ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where somethings are not working.

Can you provide some resources of things that might not work?

With the current implementation of hashes being calculated everytime

This isn't true. Most hashes are calculated at build time. The only hashes calculated at runtime are the ones that belong to Server Islands

You only have to inject the nonce token in the scripts and your done

This might cover your use case, but it doesn't cover cases like SPA applications where scripts might be added dynamically using a client router.

- SSG, especially when no adapters are involved.
- SPA (Single Page Applications), where scripts and styles *might* be loaded dynamically using client-side routing.

## Generation of hashes

The hashes will be generated using the [`crypto`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto) global object, and the package [`@oslojs/encoding`](https://encoding.oslojs.dev/). Astro *already uses* this package to encrypt props for server islands.

Hashes will be generated using the `SHA-256` algorithm. Users can't change the algorithm.

## Known scripts and styles

When using client islands, Astro injects **known** styles (Astro island) and scripts (Astro island and directives). These assets are known at build time, so Astro calculates the hashes at build time and stores them in the manifest. The rendering engine will just read and render them.

## `ClientRouter` (AKA view transitions)

The client router has some complex logic to accommodate many use cases. Some of those use cases involve the swapping of scripts during the transition from one page to another.

The swapping of scripts is restricted in CSP, and can only be allowed using the [`strict-dynamic`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP#the_strict-dynamic_keyword) policy.

We might enable the client router only via configuration.

```js
export default defineConfig({
security: {
csp: {
strictDynamic: true
}
}
})
```

We want to expose a configuration because sometimes it's very easy to miss the usage of `ClientRouter`, while an explicit configuration will work for the `ClientRouter` **and** userland scripts. We don't want to enable a policy **implicitly** without the user's consent.

## Server Islands

Server islands are very dynamic because the contents of their rendered scripts depend on `props` and `slots`. Due to this requirement, the server islands must support head propagation. The compiler will ensure that the correct metadata is emitted.

The hashes **will be generated during the rendering phase**. The performance hit is proportional to the number of server islands used in a page.

## User styles

Compiled styles are calculated during the build, and Astro already possesses all the information on the styles imported and rendered by page. During the build, Astro will calculate the hashes of those assets and store them in the manifest. The rendering engine will just read and render them.

## Rendered `<meta>`

The current proposal targets scripts and styles emitted by Astro, so the policy rendered will handle only `style-src` and `script-src`:
```html
<meta
http-equiv="Content-Security-Policy"
content="style-src 'self' 'sha256-hash1'; script-src 'self' 'sha256-hash2'"
>
```
While browsers are able to [merge policies](https://centralcsp.com/articles/csp-meta-tags) from `Response` headers and `meta` tags, Astro will provide runtime APIs so users can push their custom policies based in their requirements.

### Configuration APIs

New configurations will be available in case policies and/or hashes can be applied to all pages

```js
export default defineConfig({
security: {
csp: {
// Applied only to `script-src` policy
strictDynamic: true,
// Additional policies added to the final content
policies: [
"default-src: 'self'",
"img-src 'self'"
],
// List of hashes for `style-src`. Value provided must be
// with `sha256-`/`sha512-`
styleHashes: [
"sha512-hash1",
"sha256-hash1",
"sha384-hash1",
],
// List of hashes for `script-src`. Value provided must be
// with `sha256-`/`sha512-`
scriptHashes: [
"sha512-hash1",
"sha256-hash1",
"sha384-hash1",
],
}
}
})
```

### Runtime APIs

The following paragraph will describe possible runtime APIs that users can use to customise their CSP policies. When `Astro.` is used, it's intended that also `APIContext` is covered by the implementation.

#### `Astro.insertCspPolicy(policy: string)`

Users can add an entire policy. Astro won't check if the policy is grammatically correct.

```js
Astro.insertCspPolicy(`image-src '${Astro.site}'`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this API covers injecting hashes directly eg.

Astro.insertCspPolicy(`script-src 'self' '${myHash}'`)

I like this, it's better then my proposal on Discord to have it on insertCspAsset

Copy link
Member Author

@ematipico ematipico May 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we can't provide such API, in fact I think I will try to make it more strict. Astro must control the script-src and style-src directives, because that's where we append all the hashes. If a user provides their own directive, they override ours, and it's all for nought

```
Result
```html
<meta
http-equiv="Content-Security-Policy"
content="image-src 'https://example.com'; style-src 'self' 'sha256-hash1'; script-src 'self' 'sha256-hash2'"
>
```

#### `Astro.insertCspAsset({ content: string, kind: "script" | "style" })`

```js
Astro.insertCspAsset({
content: "console.log(sort)",
kind: "script"
})
```
Result
```html
<meta
http-equiv="Content-Security-Policy"
content="style-src 'self' 'sha256-hash1'; script-src 'self' 'sha256-hash2' 'sha256-<NEW_HASH>'"
>
```
Where `<NEW_HASH>` is the hash generated by Astro from the `console.log(sort)` content.


# Testing Strategy

Types of tests:
- unit tests
- integration tests
- e2e tests

Features to test:
- client islands
- server islands
- view transitions
- external scripts (via APIs)
- external styles (via APIs)
- inline scripts
- inline styles

# Drawbacks

The hashes solution will negatively impact the rendering engine because Astro will have to calculate the hashes **at runtime**. The most expensive solution is the support of server islands because the contents of their scripts depend on props and slots, which are only known during the rendering phase.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know how big the impact is? Genuinely curious

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, but we can set up a codspeed benchmark

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a comparable solution, it would take around 500ms for dozens of pages in a Netlify Free tier build. This should be the base performance


However, users are willing to compromise some performance penalties for security.

## Dev server

`vite` works differently in dev and prod. In dev, all assets (scripts and styles) are inlined, and also replaced using HRM. After the build, these assets are bundled, minified and imported differently.

Due to this significant discrepancy, supporting the dev server will be very difficult, so we will focus on the build first.

# Alternatives

## The `nonce` solution

This solution is way **easier** to implement; however, it won't work for static pages (SSG).
As highlighted in this [comment of mine](https://github.com/withastro/roadmap/issues/1149#issuecomment-2824552131), in order to provide the `nonce` header, a user would need to use **an edge function** in a serverless environment or middleware for server environments. This means that environments that don't provide any virtual host control (e.g. GitHub pages, etc.), the solution wouldn't work *at all*.

This solution doesn't follow Astro core principles, where an Astro site should work *out of the box* with the majority of environments, especially SSG, which is a first-class citizen for us.


# Adoption strategy

The usage of this feature will be under an experimental flag, and shipped in a minor:

```js
export default defineConfig({
experimental: {
csp: true
}
})
```

After a period of trial and test with existing Astro functionalities, the experimental flag will be removed.

The feature will always be **opt-in**.

# Unresolved Questions