From 39399b75b86a82bc249aa8063e9ae878d0a0935b Mon Sep 17 00:00:00 2001 From: iliana etaoin Date: Thu, 11 Apr 2024 17:11:24 -0700 Subject: [PATCH 01/16] use a content-security-policy in development --- vercel.json | 13 +++++++++++++ vite.config.ts | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/vercel.json b/vercel.json index ac33c67793..be97e34e95 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,19 @@ { "buildCommand": "API_MODE=msw npm run build && cp mockServiceWorker.js dist/", "outputDirectory": "dist", + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "content-security-policy", + "value": "default-src 'self'; style-src 'unsafe-inline' 'self'; frame-src 'none'; object-src 'none'; form-action 'none'; frame-ancestors 'none'" + }, + { "key": "x-content-type-options", "value": "nosniff" }, + { "key": "x-frame-options", "value": "DENY" } + ] + } + ], "rewrites": [ { "source": "/viewscript.js", diff --git a/vite.config.ts b/vite.config.ts index 5d96f3875b..1c30db656c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { randomBytes } from 'crypto' import { resolve } from 'path' import basicSsl from '@vitejs/plugin-basic-ssl' import react from '@vitejs/plugin-react-swc' @@ -13,6 +14,8 @@ import { createHtmlPlugin } from 'vite-plugin-html' import tsconfigPaths from 'vite-tsconfig-paths' import { z } from 'zod' +import vercelConfig from './vercel.json' + const ApiMode = z.enum(['msw', 'dogfood', 'nexus']) const apiModeResult = ApiMode.default('nexus').safeParse(process.env.API_MODE) @@ -67,6 +70,11 @@ const previewMetaTag = [ }, ] +const headers = Object.fromEntries( + vercelConfig.headers[0].headers.map(({ key, value }) => [key, value]) +) +const cspNonce = randomBytes(8).toString('hex') + // see https://vitejs.dev/config/ export default defineConfig(({ mode }) => ({ build: { @@ -79,6 +87,8 @@ export default defineConfig(({ mode }) => ({ app: 'index.html', }, }, + // prevent inlining assets as `data:`, which is not permitted by our Content-Security-Policy + assetsInlineLimit: 0, }, define: { 'process.env.MSW': JSON.stringify(apiMode === 'msw'), @@ -99,8 +109,20 @@ export default defineConfig(({ mode }) => ({ react(), apiMode === 'dogfood' && basicSsl(), ], + html: { + cspNonce: + mode === 'production' + ? // don't include a placeholder nonce in production + undefined + : // use a CSP nonce to avoid needing to permit 'unsafe-inline' in dev mode + cspNonce, + }, server: { port: 4000, + headers: { + ...headers, + 'content-security-policy': `${headers['content-security-policy']}; script-src 'nonce-${cspNonce}' 'self'`, + }, // these only get hit when MSW doesn't intercept the request proxy: { '/v1': { @@ -119,6 +141,9 @@ export default defineConfig(({ mode }) => ({ }, }, }, + preview: { + headers: headers, + }, test: { environment: 'jsdom', setupFiles: ['test/unit/setup.ts'], From f93d6161f61498bfb7a7006ff27c9f2e0cf152e4 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 12 Apr 2024 13:39:36 -0500 Subject: [PATCH 02/16] cram things in a bit --- vite.config.ts | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 1c30db656c..860236bf1d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -70,10 +70,11 @@ const previewMetaTag = [ }, ] -const headers = Object.fromEntries( - vercelConfig.headers[0].headers.map(({ key, value }) => [key, value]) -) +// vercel config is source of truth for headers +const vercelHeaders = vercelConfig.headers[0].headers +const headers = Object.fromEntries(vercelHeaders.map((h) => [h.key, h.value])) const cspNonce = randomBytes(8).toString('hex') +const cspWithNonce = `${headers['content-security-policy']}; script-src 'nonce-${cspNonce}' 'self'` // see https://vitejs.dev/config/ export default defineConfig(({ mode }) => ({ @@ -110,19 +111,13 @@ export default defineConfig(({ mode }) => ({ apiMode === 'dogfood' && basicSsl(), ], html: { - cspNonce: - mode === 'production' - ? // don't include a placeholder nonce in production - undefined - : // use a CSP nonce to avoid needing to permit 'unsafe-inline' in dev mode - cspNonce, + // don't include a placeholder nonce in production. + // use a CSP nonce in dev to avoid needing to permit 'unsafe-inline' + cspNonce: mode === 'production' ? undefined : cspNonce, }, server: { port: 4000, - headers: { - ...headers, - 'content-security-policy': `${headers['content-security-policy']}; script-src 'nonce-${cspNonce}' 'self'`, - }, + headers: { ...headers, 'content-security-policy': cspWithNonce }, // these only get hit when MSW doesn't intercept the request proxy: { '/v1': { @@ -141,9 +136,7 @@ export default defineConfig(({ mode }) => ({ }, }, }, - preview: { - headers: headers, - }, + preview: { headers }, test: { environment: 'jsdom', setupFiles: ['test/unit/setup.ts'], From 36353d80717cf55c614a27105ee0da1e0e72679d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 12 Apr 2024 13:59:21 -0500 Subject: [PATCH 03/16] playwright test for dev mode headers --- test/e2e/meta.e2e.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 test/e2e/meta.e2e.ts diff --git a/test/e2e/meta.e2e.ts b/test/e2e/meta.e2e.ts new file mode 100644 index 0000000000..5e9210300b --- /dev/null +++ b/test/e2e/meta.e2e.ts @@ -0,0 +1,14 @@ +import { expect, test } from '@playwright/test' + +test('CSP headers', async ({ page }) => { + // doesn't matter what page we go to + const response = await page.goto('/') + expect(response?.headers()).toMatchObject({ + // note nonce is represented as [0-9a-f]+ + 'content-security-policy': expect.stringMatching( + /^default-src 'self'; style-src 'unsafe-inline' 'self'; frame-src 'none'; object-src 'none'; form-action 'none'; frame-ancestors 'none'; script-src 'nonce-[0-9a-f]+' 'self'$/ + ), + 'x-content-type-options': 'nosniff', + 'x-frame-options': 'DENY', + }) +}) From d2706f53418aea9f34bd985b2fe0d7ca1b35f3a3 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 12 Apr 2024 14:11:44 -0500 Subject: [PATCH 04/16] fix license, add draft docs/csp-headers.md --- docs/csp-headers.md | 18 ++++++++++++++++++ test/e2e/meta.e2e.ts | 7 +++++++ 2 files changed, 25 insertions(+) create mode 100644 docs/csp-headers.md diff --git a/docs/csp-headers.md b/docs/csp-headers.md new file mode 100644 index 0000000000..df8b7a2e4d --- /dev/null +++ b/docs/csp-headers.md @@ -0,0 +1,18 @@ +# CSP headers in development and on Vercel + +Note: production headers are set server-side in Nexus. The headers set in this repo are meant to match what is set there (and should be kept in sync as far as possible) but real header changes must be made in the Omicron repo. + +The base headers are defined in `vercel.json` and then imported into `vite.config.ts` to avoid repeating them. + +The `content-security-policy` is based on the recommendation by the [OWASP Secure Headers Project](https://owasp.org/www-project-secure-headers/index.html) (click the "Best Practices" tab). The directives: + +- `default-src 'self'`: By default, restrict all resources to same-origin. +- `style-src 'unsafe-inline' 'self'`: Restrict CSS to same-origin and inline use. `style=` attributes on React elements seem to count as inline. +- `frame-src 'none'`: Disallow nested browsing contexts (`` and `