A Content Security Policy (CSP) tells the browser which scripts a page may run. If an attacker manages to inject markup, a <script> or an onclick="..." attribute, the browser refuses to run it. We set up a strict CSP for an Angular SPA sent by a server. This blog is a static site built with Astro and hosted on Netlify: there is no server to compute a nonce, and the pages load third parties (analytics, comments, a donation widget, search).
Since version 6, Astro can build the policy itself. In this article, we will add a CSP to this blog with security.csp, see what it hashes and what it does not, why 'strict-dynamic' did not work here, and how browser tests make sure the policy never breaks a page.
What Astro generates
With security.csp enabled, Astro adds a <meta http-equiv="content-security-policy"> element to every page. The script-src directive lists a SHA-256 hash of each inline script on the page. A static host has nothing else to do: no nonce, no header computed per request.
// astro.config.mjs
export default defineConfig({
security: {
csp: {
directives: ["object-src 'none'", "base-uri 'self'", "form-action 'self'"],
scriptDirective: {
resources: [
"'self'",
"https://www.googletagmanager.com",
"https://cdnjs.buymeacoffee.com",
"https://*.disqus.com",
"https://*.disquscdn.com",
],
},
styleDirective: { resources: ["'self'", "'unsafe-inline'"] },
},
},
});The built page then carries:
<meta http-equiv="content-security-policy" content="object-src 'none';base-uri 'self';form-action 'self';
script-src 'self' https://www.googletagmanager.com https://cdnjs.buymeacoffee.com https://*.disqus.com
https://*.disquscdn.com 'sha256-xIb/Pa7F…' 'sha256-1BvDVWLr…' …; style-src 'self' 'unsafe-inline';">Three things are worth knowing before going further:
- The policy is not applied in development. Astro only writes it at build time, so test with
astro build, then servedist/. - A
<meta>policy cannot set everything.frame-ancestors,report-uriandsandboxare ignored in a meta tag. Clickjacking protection stays in a header: this site keepsX-Frame-Options: DENYinnetlify.toml. - Styles stay open. React's
styleprops and the articles' Markdown writestyleattributes, which a hash cannot allow. When'unsafe-inline'is in a directive, Astro does not add hashes to it, which would otherwise disable'unsafe-inline'. Styles cannot run code, so the risk stays low.
What gets hashed, and what does not
On this blog, Astro hashed every inline script it rendered: the theme script that avoids a flash of the wrong theme, the script that scrolls to a heading on load, and the island loaders. One did not make it: the analytics bootstrap, written with set:html. The browser blocked it, and analytics silently stopped.
Instead of adding a hash by hand, the code moved to a module of the site, which 'self' allows. The module also creates the external script itself (on this blog, only once the reader has agreed to it in the cookie banner):
// A module of the site, loaded from the layout
window.dataLayer = window.dataLayer || [];
window.gtag = function gtag() {
// gtag.js reads the arguments object itself, not an array.
// eslint-disable-next-line prefer-rest-params
window.dataLayer.push(arguments);
};
window.gtag("js", new Date());
window.gtag("config", site.analyticsId);
const script = document.createElement("script");
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${site.analyticsId}`;
document.head.appendChild(script);Inline event handlers are the other casualty: onclick="..." is never allowed by a hash-based policy. The copy buttons of the code blocks, generated by the Markdown pipeline, used one. The code now travels in a data-code attribute, and a single listener handles every button:
// src/scripts/code-buttons.ts
document.addEventListener("click", (event) => {
const container = (event.target as Element | null)?.closest<HTMLElement>(
".gatsby-code-button-container[data-code]",
);
if (!container) return;
copy(container.dataset.code ?? "");
showToaster(container);
});Why not 'strict-dynamic'
The strict CSP recommended by Google uses 'strict-dynamic': hashed scripts are trusted, the scripts they load are trusted, and host lists become unnecessary. Astro supports it with scriptDirective.strictDynamic, and it was the first thing we tried.
On this site, Chromium then refused the page's own module script and the components the islands load: the navbar, the table of contents, the comments, the contact form. With 'strict-dynamic', 'self' and host lists are ignored, and a <script type="module" src="..."> written in the HTML needs a nonce of its own, or a hash with a matching integrity attribute, which Astro does not add to its scripts. Every interactive part of the site stopped working, without any visible error on the page.
So the policy uses an allowlist instead: 'self', the hashes, and the four third-party hosts. It still blocks what matters most: an injected inline script, an inline handler, a script from any other host, a plugin (object-src 'none'), and a <base> or form pointing elsewhere.
The CMS stays out
/admin/ runs Decap CMS, which evaluates code, injects its own scripts and loads Netlify Identity. It is also an Astro page, so it gets the policy too. Rather than widening the policy for every reader, a small integration removes it from that one page after the build:
// astro.config.mjs, in an integration
"astro:build:done": async ({ dir }) => {
const admin = new URL("admin/index.html", dir);
const html = await readFile(admin, "utf8");
await writeFile(admin, html.replace(/<meta http-equiv="content-security-policy"[^>]*>/, ""));
},Tests that fail on a violation
A CSP that blocks something does not throw an error the page can see: the feature just stops. The browser does fire a securitypolicyviolation event, so every Playwright test of this blog records them and fails if there is one:
// tests/e2e/fixtures.ts
export const test = base.extend<{ page: Page }>({
page: async ({ page }, use) => {
await page.addInitScript(() => {
window.__csp = [];
document.addEventListener("securitypolicyviolation", (event) =>
window.__csp.push(`${event.effectiveDirective} ${event.blockedURI}`),
);
});
await use(page);
expect(await page.evaluate(() => window.__csp)).toEqual([]);
},
});The tests click through the site, so the scripts each feature loads run too: the search dialog, the theme menu, the copy buttons, the table of contents, the comments and the contact form. A site check run on the build also makes sure every page carries a policy with 'self' and object-src 'none', and that no page contains an inline handler.
The third parties are blocked in those tests, so we also loaded the deploy preview with them enabled. Analytics, the donation widget, the search and the Disqus comments all worked, and one script was refused: d-code.liadm.com, an ad tracker that Disqus injects into the host page. The comments work without it, so it stays blocked.
One header less
X-XSS-Protection came from the previous setup. Browsers have removed the filter it switched on, and MDN advises against it: it is gone, the CSP takes its place.
Conclusion
On a static Astro site, security.csp gives a hash-based policy for free. What remains is to make the page fit it: no inline handlers, no set:html scripts, styles left open on purpose, and the CMS kept apart. 'strict-dynamic' sounds stricter, but on a site made of islands, check that the islands still load before keeping it. A test that fails on any securitypolicyviolation is what keeps the policy honest as the site changes.
References
- Astro: security.csp — the configuration, its directives and limits.
- Content Security Policy guide — MDN's introduction.
- script-src — hashes,
'strict-dynamic'and host lists. - http-equiv="content-security-policy" — what a meta policy can and cannot set.
- Mitigate XSS with a strict CSP — Google's nonce- and hash-based guidance.
- securitypolicyviolation event — detecting violations in tests.
- X-XSS-Protection — why the header is obsolete.
- OWASP Content Security Policy Cheat Sheet — directive-by-directive advice.
- Playwright fixtures — extending
pagefor every test.