SvelteKit Content Security Policy

SvelteKit Content Security Policy

CSP for XSS Protection

😕 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’ 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.

⚙️ Configuration

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

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:

/** @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’ 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:

/** @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.

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’s magic work:

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.

This is all good, but when I deployed to Netlify and ran a test using the securityheaders.com 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 HTTP header is a stronger approach in most cases. 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.

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 4753 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 3440 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:

/*
  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:

{
  "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.

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.

This limitation is mentioned in the SvelteKit CSP pull request. 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’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 Element as well as Twitter @mention

You can see the full code for this SvelteKit Content Security Policy post in the Rodney Lab Git Hub repo.

🙏🏽 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 consider supporting me through Buy me a Coffee.

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 @askRodney on Twitter and also askRodney on Telegram. Also, see further ways to get in touch with Rodney Lab. I post regularly on SvelteKit as well as Search Engine Optimisation among other topics. Also subscribe to the newsletter to keep up-to-date with our latest projects.

Did you find this article valuable?

Support Ask Rodney by becoming a sponsor. Any amount is appreciated!