Skip to content

Commit

Permalink
use a content-security-policy in development (#2142)
Browse files Browse the repository at this point in the history
* use a content-security-policy in development

* cram things in a bit

* playwright test for dev mode headers

* fix license, add draft docs/csp-headers.md

* add preview script to package.json

* comment explaining why we use the nonce thing, add justification to doc

* remove style-src 'unsafe-inline' :)

* fix the docs and test

* apply patch for react-focus-guards

* patch global styles out of react-remove-scroll

* `patch-package --reverse` before vercel caches it

* patch out the other react-remove-scroll-bar reference

* save another 31 bytes, why not

* Revert "remove style-src 'unsafe-inline' :)"

This reverts commit 22f40bb.

* Revert "fix the docs and test"

This reverts commit 06e13cd.

* update docs

---------

Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
  • Loading branch information
iliana and david-crespo authored Apr 23, 2024
1 parent 2b6d0f7 commit d2a9345
Show file tree
Hide file tree
Showing 7 changed files with 171 additions and 1 deletion.
22 changes: 22 additions & 0 deletions docs/csp-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# CSP headers in local dev and on Vercel

## Why

Production CSP headers are set server-side in Nexus, so why should we set the headers on Vercel and the Vite dev server too? We are not _that_ concerned about security in those environments. The main reason is so we can know as early as possible in the development process whether a given CSP directive breaks something the web console.

## What

The base headers are defined in `vercel.json` and 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. See #2183 for eventually removing `'unsafe-inline'`
- `frame-src 'none'`: Disallow nested browsing contexts (`<frame>` and `<iframe>`).
- `object-src 'none'`: Disallow `<object>` and `<embed>`.
- `form-action 'none'`: Disallow submitting any forms with an `action` attribute (none of our forms are the traditional kind and instead post to the server in JS).
- `frame-ancestors 'none'`: Disallow embedding this site with things like `<iframe>`; used to prevent click-jacking attacks.

In development mode, an additional `script-src` CSP directive is added which references a randomly-generated nonce. [Vite injects this in the generated index.html](https://vitejs.dev/guide/features.html#content-security-policy-csp) so that the dev-mode scripts can load. We do this instead of allowing `'unsafe-inline'` because I'm not sure whether tests run against dev bits or not, and this helps get dev builds much closer to production.

Also set are `x-content-type-options: nosniff` and `x-frame-options: DENY`.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"start:msw": "API_MODE=msw vite",
"start:nexus": "API_MODE=nexus vite",
"start:dogfood": "API_MODE=dogfood vite",
"preview": "API_MODE=msw npm run build && cp mockServiceWorker.js dist/ && vite preview",
"dev": "API_MODE=msw vite",
"start:mock-api": "node -r esbuild-register ./tools/start_mock_api.ts",
"build": "vite build",
Expand Down
18 changes: 18 additions & 0 deletions patches/@radix-ui+react-focus-guards+1.0.1.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Upstream PR: https://github.com/radix-ui/primitives/pull/2840

diff --git a/node_modules/@radix-ui/react-focus-guards/dist/index.mjs b/node_modules/@radix-ui/react-focus-guards/dist/index.mjs
index cb0f892..4e56fb8 100644
--- a/node_modules/@radix-ui/react-focus-guards/dist/index.mjs
+++ b/node_modules/@radix-ui/react-focus-guards/dist/index.mjs
@@ -27,7 +27,10 @@ function $3db38b7d1fb3fe6a$var$createFocusGuard() {
const element = document.createElement('span');
element.setAttribute('data-radix-focus-guard', '');
element.tabIndex = 0;
- element.style.cssText = 'outline: none; opacity: 0; position: fixed; pointer-events: none';
+ element.style.outline = 'none';
+ element.style.opacity = '0';
+ element.style.position = 'fixed';
+ element.style.pointerEvents = 'none';
return element;
}
const $3db38b7d1fb3fe6a$export$be92b6f5f03c0fe9 = $3db38b7d1fb3fe6a$export$ac5b58043b79449b;
68 changes: 68 additions & 0 deletions patches/react-remove-scroll+2.5.5.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
diff --git a/node_modules/react-remove-scroll/dist/es2015/SideEffect.js b/node_modules/react-remove-scroll/dist/es2015/SideEffect.js
index 08eda83..e48ccd6 100644
--- a/node_modules/react-remove-scroll/dist/es2015/SideEffect.js
+++ b/node_modules/react-remove-scroll/dist/es2015/SideEffect.js
@@ -1,7 +1,4 @@
-import { __spreadArray } from "tslib";
import * as React from 'react';
-import { RemoveScrollBar } from 'react-remove-scroll-bar';
-import { styleSingleton } from 'react-style-singleton';
import { nonPassive } from './aggresiveCapture';
import { handleScroll, locationCouldBeScrolled } from './handleScroll';
export var getTouchXY = function (event) {
@@ -19,24 +16,11 @@ export function RemoveScrollSideCar(props) {
var shouldPreventQueue = React.useRef([]);
var touchStartRef = React.useRef([0, 0]);
var activeAxis = React.useRef();
- var id = React.useState(idCounter++)[0];
- var Style = React.useState(function () { return styleSingleton(); })[0];
+ var Style = React.useState({})[0];
var lastProps = React.useRef(props);
React.useEffect(function () {
lastProps.current = props;
}, [props]);
- React.useEffect(function () {
- if (props.inert) {
- document.body.classList.add("block-interactivity-".concat(id));
- var allow_1 = __spreadArray([props.lockRef.current], (props.shards || []).map(extractRef), true).filter(Boolean);
- allow_1.forEach(function (el) { return el.classList.add("allow-interactivity-".concat(id)); });
- return function () {
- document.body.classList.remove("block-interactivity-".concat(id));
- allow_1.forEach(function (el) { return el.classList.remove("allow-interactivity-".concat(id)); });
- };
- }
- return;
- }, [props.inert, props.lockRef.current, props.shards]);
var shouldCancelEvent = React.useCallback(function (event, parent) {
if ('touches' in event && event.touches.length === 2) {
return !lastProps.current.allowPinchZoom;
@@ -139,8 +123,5 @@ export function RemoveScrollSideCar(props) {
document.removeEventListener('touchstart', scrollTouchStart, nonPassive);
};
}, []);
- var removeScrollBar = props.removeScrollBar, inert = props.inert;
- return (React.createElement(React.Fragment, null,
- inert ? React.createElement(Style, { styles: generateStyle(id) }) : null,
- removeScrollBar ? React.createElement(RemoveScrollBar, { gapMode: "margin" }) : null));
+ return (React.createElement(React.Fragment, null));
}
diff --git a/node_modules/react-remove-scroll/dist/es2015/UI.js b/node_modules/react-remove-scroll/dist/es2015/UI.js
index 26c94a8..75d91ae 100644
--- a/node_modules/react-remove-scroll/dist/es2015/UI.js
+++ b/node_modules/react-remove-scroll/dist/es2015/UI.js
@@ -1,6 +1,5 @@
import { __assign, __rest } from "tslib";
import * as React from 'react';
-import { fullWidthClassName, zeroRightClassName } from 'react-remove-scroll-bar/constants';
import { useMergeRefs } from 'use-callback-ref';
import { effectCar } from './medium';
var nothing = function () {
@@ -29,8 +28,4 @@ RemoveScroll.defaultProps = {
removeScrollBar: true,
inert: false,
};
-RemoveScroll.classNames = {
- fullWidth: fullWidthClassName,
- zeroRight: zeroRightClassName,
-};
export { RemoveScroll };
21 changes: 21 additions & 0 deletions test/e2e/meta.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
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',
})
})
15 changes: 14 additions & 1 deletion vercel.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
{
"buildCommand": "API_MODE=msw npm run build && cp mockServiceWorker.js dist/",
"buildCommand": "API_MODE=msw npm run build && cp mockServiceWorker.js dist/ && npx patch-package --reverse",
"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",
Expand Down
27 changes: 27 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand Down Expand Up @@ -67,6 +70,21 @@ const previewMetaTag = [
},
]

// 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]))

// This is only needed for local dev to avoid breaking Vite's script injection.
// Rather than use unsafe-inline all the time, the nonce approach is much more
// narrowly scoped and lets us make sure everything *else* works fine without
// unsafe-inline.
const cspNonce = randomBytes(8).toString('hex')
const csp = headers['content-security-policy']
const devHeaders = {
...headers,
'content-security-policy': `${csp}; script-src 'nonce-${cspNonce}' 'self'`,
}

// see https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
build: {
Expand All @@ -79,6 +97,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'),
Expand All @@ -99,8 +119,14 @@ export default defineConfig(({ mode }) => ({
react(),
apiMode === 'dogfood' && basicSsl(),
],
html: {
// 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: devHeaders,
// these only get hit when MSW doesn't intercept the request
proxy: {
'/v1': {
Expand All @@ -119,6 +145,7 @@ export default defineConfig(({ mode }) => ({
},
},
},
preview: { headers },
test: {
environment: 'jsdom',
setupFiles: ['test/unit/setup.ts'],
Expand Down

0 comments on commit d2a9345

Please sign in to comment.