Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

use a content-security-policy in development #2142

Merged
merged 19 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this because I added more content to an existing patch in a commit, which made Vercel break. Turns out it caches your node_modules at the end of the run, and patch-package only knows not to apply a patch if it's entirely applied.

ds300/patch-package#37 has some related chatter.

"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
Loading