SvelteKit Content Security Policy: CSP for XSS Protection
Tags: webdev, security, javascript, svelte
Published: 2022-08-05 11:35:16

---
title: "SvelteKit Content Security Policy: CSP for XSS Protection"
published: "true"
description: "SvelteKit Content Security Policy: how you can add CSP to reduce your Svelte site's cross-site scripting (XSS) attack surface."
tags: "webdev, security, javascript, svelte"
canonical_url: "https://rodneylab.com/sveltekit-content-security-policy/"
cover_image: "https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ivunc03vz7rrbtq8lj3e.png"
---

## 😕 What is Content Security Policy?

Today we look at SvelteKit Content Security Policy.  Content Security Policy is a set of meta you can send from your server to visitors&rsquo; browsers to help improve security.  It is designed to reduce the **cross site scripting (XSS)** attack surface.  At its core, the script directives help the browser identify foreign scripts which might have been injected by a malicious party.  However, content security policy covers **styles**, **images** and **other resources** beyond scripts. 

We see with scripts we can compute a **cryptographic hash** of the intended script on the server and send this with the page.  By hashing the received script itself and comparing to the list of CSP hashes, the browser can potentially spot injected malicious scripts.  We will see hashing is not the only choice and see when you might consider using the alternatives.  SvelteKit got a patch in February which lets it automatically compute script hashes and inject a content security policy tag in the page head.

You should only attempt to update content security policy if you are **confident** you know what you are doing.  It is possible to completely stop a site from rendering with the wrong policy.

## 🔬 What is our Focus?

We will see why and how we can use the SvelteKit generated CSP meta tag to add an HTTP Content Security Policy header to a static site. As well as that, we also look at the configuration for deploying the site with headers to Netlify and Cloudflare Pages.  We will use the SvelteKit MDsveX blog starter, though the approach should work well with other sites.  This should all get us an A rating on SecurityHeaders.com for the site.

![SvelteKit Content Security Policy: Screenshot shows summary of HTTP headers scan by Security Headers dot com with an A rating.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6me8owiih9jffdrwfqd6.png)

## ⚙️ Configuration

If you want to code along, then clone the  SvelteKit MDsveX blog starter and install packages:

```shell
git clone https://github.com/rodneylab/sveltekit-blog-mdx.git sveltekit-content-security-policy
cd sveltekit-content-security-policy
pnpm install
pnpm run dev
```

We just need to update `svelte.config.js` to have it create the CSP meta for us:

```javascript
/** @type {import('@sveltejs/kit').Config} */
import adapter from "@sveltejs/adapter-static";
import { imagetools } from "vite-imagetools";
import { mdsvex } from "mdsvex";
import preprocess from "svelte-preprocess";

const config = {
  extensions: [".svelte", ".md", ".svelte.md"],
  preprocess: [
    mdsvex({ extensions: [".svelte.md", ".md", ".svx"] }),
    preprocess({
      scss: {
        prependData: "@import 'src/lib/styles/variables.scss';",
      },
    }),
  ],
  kit: {
    adapter: adapter({ precompress: true }),
    csp: {
      mode: "hash",
      directives: { "script-src": ["self"] },
    },
    files: {
      hooks: "src/hooks",
    },
    prerender: { default: true },
    vite: {
      define: {
        "process.env.VITE_BUILD_TIME": JSON.stringify(new Date().toISOString()),
      },
      plugins: [imagetools({ force: true })],
    },
  },
};

export default config;
```

We can set mode to `hash`, `nonce` or `auto`.  `hash` will compute a SHA256 cryptographic hash of all scripts which SvelteKit generates in building the site.  These scripts are later used by visitors&rsquo; browsers to sniff out foul play.  Hashes are a good choice for static sites.  This is because scripts are fixed on build and will not change until you rebuild the site.

With SSR sites, SvelteKit might generate a different script for each request.  To avoid the extra overhead of computing a set of hashes for each request an alternative is to use a **nonce**.  The nonce is just a randomly generated string.  We just add the nonce to each script tag and also include it in the CSP meta.  Now the browser just checks the nonce in the script match the one in the meta.  For this to work best, we need to generate a new random nonce with each request.

The third option, `auto`, simply chooses `hash` for prerendered content and `nonce` for anything else.

### Alternative Configuration

This configuration (above) is a little basic.  You might want to be a bit more extensive configuration.  In this case it makes more sense to extract the configuration to a separate file.  In this case you can update `svelte.config.js` like so:

```javascript
/** @type {import('@sveltejs/kit').Config} */
import adapter from '@sveltejs/adapter-static';
import { imagetools } from 'vite-imagetools';
import { mdsvex } from 'mdsvex';
import preprocess from 'svelte-preprocess';
import cspDirectives from './csp-directives.mjs';

const config = {
  extensions: ['.svelte', '.md', '.svelte.md'],
  preprocess: [
    mdsvex({ extensions: ['.svelte.md', '.md', '.svx'] }),
    preprocess({
      scss: {
        prependData: "@import 'src/lib/styles/variables.scss';",
      },
    }),
  ],
  kit: {
    adapter: adapter({ precompress: true }),
    csp: {
      mode: 'hash',
      directives: cspDirectives,
    },
    files: {
      hooks: 'src/hooks',
    },
    prerender: { default: true },
    vite: {
      define: {
        'process.env.VITE_BUILD_TIME': JSON.stringify(new Date().toISOString()),
      },
      plugins: [imagetools({ force: true })],
    },
  },
};

export default config;
```

Here is one possible set of values you might use.  Of course this will not match your use case and you should determine a set of values which are suitable.

```javascript
const rootDomain = process.env.VITE_DOMAIN; // or your server IP for dev

const cspDirectives = {
  'base-uri': ["'self'"],
  'child-src': ["'self'"],
  'connect-src': ["'self'", 'ws://localhost:*'],
  // 'connect-src': ["'self'", 'ws://localhost:*', 'https://hcaptcha.com', 'https://*.hcaptcha.com'],
  'img-src': ["'self'", 'data:'],
  'font-src': ["'self'", 'data:'],
  'form-action': ["'self'"],
  'frame-ancestors': ["'self'"],
  'frame-src': [
    "'self'",
    // "https://*.stripe.com",
    // "https://*.facebook.com",
    // "https://*.facebook.net",
    // 'https://hcaptcha.com',
    // 'https://*.hcaptcha.com',
  ],
  'manifest-src': ["'self'"],
  'media-src': ["'self'", 'data:'],
  'object-src': ["'none'"],
  'style-src': ["'self'", "'unsafe-inline'"],
  // 'style-src': ["'self'", "'unsafe-inline'", 'https://hcaptcha.com', 'https://*.hcaptcha.com'],
  'default-src': [
    'self',
    ...(rootDomain ? [rootDomain, `ws://${rootDomain}`] : []),
    // 'https://*.google.com',
    // 'https://*.googleapis.com',
    // 'https://*.firebase.com',
    // 'https://*.gstatic.com',
    // 'https://*.cloudfunctions.net',
    // 'https://*.algolia.net',
    // 'https://*.facebook.com',
    // 'https://*.facebook.net',
    // 'https://*.stripe.com',
    // 'https://*.sentry.io',
  ],
  'script-src': [
    'self',
    // 'https://*.stripe.com',
    // 'https://*.facebook.com',
    // 'https://*.facebook.net',
    // 'https://hcaptcha.com',
    // 'https://*.hcaptcha.com',
    // 'https://*.sentry.io',
    // 'https://polyfill.io',
  ],
  'worker-src': ["'self'"],
  // remove report-to & report-uri if you do not want to use Sentry reporting
  'report-to': ["'csp-endpoint'"],
  'report-uri': [
    `https://sentry.io/api/${process.env.VITE_SENTRY_PROJECT_ID}/security/?sentry_key=${process.env.VITE_SENTRY_KEY}`,
  ],
};

export default cspDirectives;
```

## 🎬 First Attempt

You will need to build the site to see it&rsquo;s magic work:

```shell
pnpm build
pnpm preview
```

Now if you open up the Inspector in you browser dev tools, then you should be able to find a meta tag which includes the content security policy.

![SvelteKit Content Security Policy: Screenshot shows browser dev tools with Inspector open and the Content Security Policy meta tag added by Selte Kit visible.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xzf8191nol45mdwitah3.png)

This is all good, but when I deployed to Netlify and ran a test using the <a aria-label="Open security headers test site"  href="https://securityheaders.com">securityheaders.com</a> site. I was getting nothing back for CSP.  For that reason I tried an alternative approach.  An alternative to including CSP in meta tags is to use HTTP headers.  Both are valid, though the <a aria-label="Read about contens security policy" href="https://content-security-policy.com/examples/meta/">HTTP header is a stronger approach in most cases</a>.  Additionally, using HTTP headers you can add reporting, using a service like Sentry.  This gives you a heads up if users start getting CSP errors in their browser.

## 📜 Header Script

Netlify as well as Cloudflare Pages, let you specify HTTP headers for your static site by you including a `_headers` file in your `static` folder.  The hosts parse this before deploy and then remove it (so it will not be served).  My idea was to write a node script which we could run after the site is built.  That script would crawl the `build` folder for HTML files and then extract the content security meta tag and add it to a `_headers` entry for the page.

Here is the node script I wrote.  If you want to try a similar approach, hopefully it will not be too much work for you to tweak it to suit your own use case.

```javascript
import 'dotenv/config';
import fs from 'fs';
import path from 'path';
import { parse } from 'node-html-parser';

const __dirname = path.resolve();
const buildDir = path.join(__dirname, 'build');

const { VITE_SENTRY_ORG_ID, VITE_SENTRY_KEY, VITE_SENTRY_PROJECT_ID } = process.env;

function removeCspMeta(inputFile) {
  const fileContents = fs.readFileSync(inputFile, { encoding: 'utf-8' });
  const root = parse(fileContents);
  const element = root.querySelector('head meta[http-equiv="content-security-policy"]');
  const content = element.getAttribute('content');
  root.remove(element);
  return content;
}

const cspMap = new Map();

function findCspMeta(startPath, filter = /\.html$/) {
  if (!fs.existsSync(startPath)) {
    console.error(`Unable to find CSP start path: ${startPath}`);
    return;
  }
  const files = fs.readdirSync(startPath);
  files.forEach((item) => {
    const filename = path.join(startPath, item);
    const stat = fs.lstatSync(filename);
    if (stat.isDirectory()) {
      findCspMeta(filename, filter);
    } else if (filter.test(filename)) {
      cspMap.set(
        filename
          .replace(buildDir, '')
          .replace(/\.html$/, '')
          .replace(/^\/index$/, '/'),
        removeCspMeta(filename),
      );
    }
  });
}

function createHeaders() {
  const headers = `/*
  X-Frame-Options: DENY
  X-XSS-Protection: 1; mode=block
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: accelerometer=(), camera=(), document-domain=(), encrypted-media=(), gyroscope=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), geolocation=()
  Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  Report-To: {"group": "csp-endpoint", "max_age": 10886400, "endpoints": [{"url": "https://o${VITE_SENTRY_KEY}.ingest.sentry.io/api/${VITE_SENTRY_ORG_ID}/security/?sentry_key=${VITE_SENTRY_PROJECT_ID}"}]}
`;
  const cspArray = [];
  cspMap.forEach((csp, pagePath) =>
    cspArray.push(`${pagePath}\n  Content-Security-Policy: ${csp}`),
  );

  const headersFile = path.join(buildDir, '_headers');
  fs.writeFileSync(headersFile, `${headers}${cspArray.join('\n')}`);
}

async function main() {
  findCspMeta(buildDir);
  createHeaders();
}

main();
```

In lines `47`&ndash;`53` you will see I added some other HTTP headers which securityheaders.com looks for. The `findCspMeta` function, starting in line `22` is what does the heavy lifting for finding meta it the SvelteKit generated output.  We also use the `node-html-parser` package to parse the DOM efficiently. In lines `34`&ndash;`40` we add the CSP content to a map with the page path as the key.  Later we use the map to generate the `/build/_headers` file.  We write `_headers` directly to build, instead of `static` since we run this script after the SvelteKit build.

Here is an example of the script output:

```plaintext
/*
  X-Frame-Options: DENY
  X-XSS-Protection: 1; mode=block
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: accelerometer=(), camera=(), document-domain=(), encrypted-media=(), gyroscope=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), geolocation=()
  Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  Report-To: {"group": "csp-endpoint", "max_age": 10886400, "endpoints": [{"url": "https://XXX.ingest.sentry.io/api/XXX/security/?sentry_key=XXX"}]}
/best-medium-format-camera-for-starting-out
  Content-Security-Policy: child-src 'self'; default-src 'self'; frame-src 'self'; worker-src 'self'; connect-src 'self' ws://localhost:*; font-src 'self' data:; img-src 'self' data:; manifest-src 'self'; media-src 'self' data:; object-src 'none'; script-src 'self' 'sha256-KD6K876QaEoRcbVCglIUUkrVfvbkkiOzn+MUAYvIE3I=' 'sha256-zArBwCFLmTaX5PiopOgysXsLgzWtw+D2DfdI+gej1y0='; style-src 'self' 'unsafe-inline'; base-uri 'self'; form-action 'self'; report-to 'csp-endpoint'
/contact
  Content-Security-Policy: child-src 'self'; default-src 'self'; frame-src 'self'; worker-src 'self'; connect-src 'self' ws://localhost:*; font-src 'self' data:; img-src 'self' data:; manifest-src 'self'; media-src 'self' data:; object-src 'none'; script-src 'self' 'sha256-t7R4W+8Ou9kpe3an17uRnyxB95SfUTIMJ/K2z6vu0Io=' 'sha256-zArBwCFLmTaX5PiopOgysXsLgzWtw+D2DfdI+gej1y0='; style-src 'self' 'unsafe-inline'; base-uri 'self'; form-action 'self'; report-to 'csp-endpoint'
/folding-camera
  Content-Security-Policy: child-src 'self'; default-src 'self'; frame-src 'self'; worker-src 'self'; connect-src 'self' ws://localhost:*; font-src 'self' data:; img-src 'self' data:; manifest-src 'self'; media-src 'self' data:; object-src 'none'; script-src 'self' 'sha256-4xx4DsEsRBOVYIl2xwCtDOZ+mGnU01sxNiKHZH57Z6w=' 'sha256-zArBwCFLmTaX5PiopOgysXsLgzWtw+D2DfdI+gej1y0='; style-src 'self' 'unsafe-inline'; base-uri 'self'; form-action 'self'; report-to 'csp-endpoint'
/
  Content-Security-Policy: child-src 'self'; default-src 'self'; frame-src 'self'; worker-src 'self'; connect-src 'self' ws://localhost:*; font-src 'self' data:; img-src 'self' data:; manifest-src 'self'; media-src 'self' data:; object-src 'none'; script-src 'self' 'sha256-mXijveCfKQlG2poJkRRzcdCDdFOlpwhP7utTdY0mOtU=' 'sha256-zArBwCFLmTaX5PiopOgysXsLgzWtw+D2DfdI+gej1y0='; style-src 'self' 'unsafe-inline'; base-uri 'self'; form-action 'self'; report-to 'csp-endpoint'
/twin-lens-reflex-camera
  Content-Security-Policy: child-src 'self'; default-src 'self'; frame-src 'self'; worker-src 'self'; connect-src 'self' ws://localhost:*; font-src 'self' data:; img-src 'self' data:; manifest-src 'self'; media-src 'self' data:; object-src 'none'; script-src 'self' 'sha256-w5p2NquSvorJBfJewyjpg4Lm1Mzs7rALuFMPfF7I/OI=' 'sha256-zArBwCFLmTaX5PiopOgysXsLgzWtw+D2DfdI+gej1y0='; style-src 'self' 'unsafe-inline'; base-uri 'self'; form-action 'self'; report-to 'csp-endpoint'
```

To run the script, we just update the `package.json` build script:

```json
{
  "name": "sveltekit-blog-mdx",
  "version": "2.0.0",
  "scripts": {
    "dev": "svelte-kit dev --port 3030",
    "build": "npm run generate:manifest && svelte-kit build && npm run generate:headers",
    "preview": "svelte-kit preview --port 3030",
    "check": "svelte-check --fail-on-hints",
    "check:watch": "svelte-check --watch",
    "lint": "prettier --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
    "lint:scss": "stylelint \"src/**/*.{css,scss,svelte}\"",
    "format": "prettier --write --plugin-search-dir=. .",
    "generate:headers": "node ./generate-headers.js",
    "generate:images": "node ./generate-responsive-image-data.js",
    "generate:manifest": "node ./generate-manifest.js",
    "generate:sitemap": "node ./generate-sitemap.js",
    "prettier:check": "prettier --check --plugin-search-dir=. .",
    "prepare": "husky install"
  },
```

## 💯 SvelteKit Content Security Policy: Testing it Out

Redeploying to Netlify and testing with securityheaders.com once more, everything now looks better.

![SvelteKit Content Security Policy: Screenshot shows content security policy headers found by Security Headers dot com.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xizg4ju7qvsd9zk3s3nj.png)

One thing you might notice, though, is that the score is capped at A (A+ is the highest rating).  This is because, for now, we need to include the `unsafe-inline` directive for styles (see line `23` of `csp-directives.mjs`).

![SvelteKit Content Security Policy: Screenshot shows warning from Security Headers on use of unsafe-inline in styles content security policy.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q6kkiwuhoh6d93w304cy.png)

This limitation is mentioned in the <a aria-albel="Open the Content security policy pull request on the Svelte Kit repo" href="https://github.com/sveltejs/kit/pull/3499">SvelteKit CSP pull request</a>.  The note there says this will not be needed once Svelte Kit moves to using the Web Animations API.

## 🙌🏽 SvelteKit Content Security Policy: Wrapup

In this post, we have taken a peek at this new SvelteKit Content Security Policy feature.  In particular we have touched on:

- why you might go for **CSP hashes** instead of **nonces**,
- a way to extract SvelteKit&rsquo;s generated CSP meta for each page,
- how you can serve **CSP security HTTP headers** on your static SvelteKit site,

Let me know if you have different or cleaner ways of achieving the same results.  You can drop a comment below or reach out for a chat on <a aria-label="Jump into the Element Matrix chat room" href="https://matrix.to/#/%23rodney:matrix.org">Element</a> as well as <a aria-label="Mention on Twitter" href="https://twitter.com/intent/user?screen_name=@askRodney">Twitter @mention</a>

You can see the full code for this SvelteKit Content Security Policy post <a aria-label="Open the Rodney Lab Git Hub repo" href="https://github.com/rodneylab/sveltekit-content-security-policy" rel="follow noopener noreferrer">in the Rodney Lab Git Hub repo</a>.

## 🙏🏽 SvelteKit Content Security Policy: Feedback

If you have found this video useful, see links below for further related content on this site. I do hope you learned one new thing from the video. Let me know if there are any ways I can improve on it. I hope you will use the code or starter in your own projects. Be sure to share your work on Twitter, giving me a mention so I can see what you did. Finally be sure to let me know ideas for other short videos you would like to see. Read on to find ways to get in touch, further below. If you have found this post useful, even though you can only afford even a tiny contribution, please <a aria-label="Support Rodney Lab via Buy me a Coffee" href="https://rodneylab.com/giving/">consider supporting me through Buy me a Coffee</a>.

Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via <a href="https://twitter.com/messages/compose?recipient_id=1323579817258831875">@askRodney</a> on Twitter and also <a aria-label="Contact Rodney Lab via Telegram" href="https://t.me/askRodney">askRodney on Telegram</a>. Also, see <a aria-label="Get in touch with Rodney Lab" href="https://rodneylab.com/contact">further ways to get in touch with Rodney Lab</a>. I post regularly on <a aria-label="See posts on svelte kit" href="https://rodneylab.com/tags/sveltekit/">SvelteKit</a> as well as <a href="https://rodneylab.com/tags/seo" aria-label="See posts on search engine optimisation">Search Engine Optimisation</a> among other topics. Also <a aria-label="Subscribe to the Rodney Lab newsletter" href="https://rodneylab.com/about/#newsletter">subscribe to the newsletter to keep up-to-date</a> with our latest projects.