Skip to content

Commit d2a9345

Browse files
ilianadavid-crespo
andauthored
use a content-security-policy in development (#2142)
* 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 <[email protected]>
1 parent 2b6d0f7 commit d2a9345

7 files changed

+171
-1
lines changed

docs/csp-headers.md

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# CSP headers in local dev and on Vercel
2+
3+
## Why
4+
5+
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.
6+
7+
## What
8+
9+
The base headers are defined in `vercel.json` and imported into `vite.config.ts` to avoid repeating them.
10+
11+
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:
12+
13+
- `default-src 'self'`: By default, restrict all resources to same-origin.
14+
- `style-src 'unsafe-inline' 'self'`: Restrict CSS to same-origin and inline use. See #2183 for eventually removing `'unsafe-inline'`
15+
- `frame-src 'none'`: Disallow nested browsing contexts (`<frame>` and `<iframe>`).
16+
- `object-src 'none'`: Disallow `<object>` and `<embed>`.
17+
- `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).
18+
- `frame-ancestors 'none'`: Disallow embedding this site with things like `<iframe>`; used to prevent click-jacking attacks.
19+
20+
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.
21+
22+
Also set are `x-content-type-options: nosniff` and `x-frame-options: DENY`.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"start:msw": "API_MODE=msw vite",
1212
"start:nexus": "API_MODE=nexus vite",
1313
"start:dogfood": "API_MODE=dogfood vite",
14+
"preview": "API_MODE=msw npm run build && cp mockServiceWorker.js dist/ && vite preview",
1415
"dev": "API_MODE=msw vite",
1516
"start:mock-api": "node -r esbuild-register ./tools/start_mock_api.ts",
1617
"build": "vite build",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
Upstream PR: https://github.com/radix-ui/primitives/pull/2840
2+
3+
diff --git a/node_modules/@radix-ui/react-focus-guards/dist/index.mjs b/node_modules/@radix-ui/react-focus-guards/dist/index.mjs
4+
index cb0f892..4e56fb8 100644
5+
--- a/node_modules/@radix-ui/react-focus-guards/dist/index.mjs
6+
+++ b/node_modules/@radix-ui/react-focus-guards/dist/index.mjs
7+
@@ -27,7 +27,10 @@ function $3db38b7d1fb3fe6a$var$createFocusGuard() {
8+
const element = document.createElement('span');
9+
element.setAttribute('data-radix-focus-guard', '');
10+
element.tabIndex = 0;
11+
- element.style.cssText = 'outline: none; opacity: 0; position: fixed; pointer-events: none';
12+
+ element.style.outline = 'none';
13+
+ element.style.opacity = '0';
14+
+ element.style.position = 'fixed';
15+
+ element.style.pointerEvents = 'none';
16+
return element;
17+
}
18+
const $3db38b7d1fb3fe6a$export$be92b6f5f03c0fe9 = $3db38b7d1fb3fe6a$export$ac5b58043b79449b;
+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
diff --git a/node_modules/react-remove-scroll/dist/es2015/SideEffect.js b/node_modules/react-remove-scroll/dist/es2015/SideEffect.js
2+
index 08eda83..e48ccd6 100644
3+
--- a/node_modules/react-remove-scroll/dist/es2015/SideEffect.js
4+
+++ b/node_modules/react-remove-scroll/dist/es2015/SideEffect.js
5+
@@ -1,7 +1,4 @@
6+
-import { __spreadArray } from "tslib";
7+
import * as React from 'react';
8+
-import { RemoveScrollBar } from 'react-remove-scroll-bar';
9+
-import { styleSingleton } from 'react-style-singleton';
10+
import { nonPassive } from './aggresiveCapture';
11+
import { handleScroll, locationCouldBeScrolled } from './handleScroll';
12+
export var getTouchXY = function (event) {
13+
@@ -19,24 +16,11 @@ export function RemoveScrollSideCar(props) {
14+
var shouldPreventQueue = React.useRef([]);
15+
var touchStartRef = React.useRef([0, 0]);
16+
var activeAxis = React.useRef();
17+
- var id = React.useState(idCounter++)[0];
18+
- var Style = React.useState(function () { return styleSingleton(); })[0];
19+
+ var Style = React.useState({})[0];
20+
var lastProps = React.useRef(props);
21+
React.useEffect(function () {
22+
lastProps.current = props;
23+
}, [props]);
24+
- React.useEffect(function () {
25+
- if (props.inert) {
26+
- document.body.classList.add("block-interactivity-".concat(id));
27+
- var allow_1 = __spreadArray([props.lockRef.current], (props.shards || []).map(extractRef), true).filter(Boolean);
28+
- allow_1.forEach(function (el) { return el.classList.add("allow-interactivity-".concat(id)); });
29+
- return function () {
30+
- document.body.classList.remove("block-interactivity-".concat(id));
31+
- allow_1.forEach(function (el) { return el.classList.remove("allow-interactivity-".concat(id)); });
32+
- };
33+
- }
34+
- return;
35+
- }, [props.inert, props.lockRef.current, props.shards]);
36+
var shouldCancelEvent = React.useCallback(function (event, parent) {
37+
if ('touches' in event && event.touches.length === 2) {
38+
return !lastProps.current.allowPinchZoom;
39+
@@ -139,8 +123,5 @@ export function RemoveScrollSideCar(props) {
40+
document.removeEventListener('touchstart', scrollTouchStart, nonPassive);
41+
};
42+
}, []);
43+
- var removeScrollBar = props.removeScrollBar, inert = props.inert;
44+
- return (React.createElement(React.Fragment, null,
45+
- inert ? React.createElement(Style, { styles: generateStyle(id) }) : null,
46+
- removeScrollBar ? React.createElement(RemoveScrollBar, { gapMode: "margin" }) : null));
47+
+ return (React.createElement(React.Fragment, null));
48+
}
49+
diff --git a/node_modules/react-remove-scroll/dist/es2015/UI.js b/node_modules/react-remove-scroll/dist/es2015/UI.js
50+
index 26c94a8..75d91ae 100644
51+
--- a/node_modules/react-remove-scroll/dist/es2015/UI.js
52+
+++ b/node_modules/react-remove-scroll/dist/es2015/UI.js
53+
@@ -1,6 +1,5 @@
54+
import { __assign, __rest } from "tslib";
55+
import * as React from 'react';
56+
-import { fullWidthClassName, zeroRightClassName } from 'react-remove-scroll-bar/constants';
57+
import { useMergeRefs } from 'use-callback-ref';
58+
import { effectCar } from './medium';
59+
var nothing = function () {
60+
@@ -29,8 +28,4 @@ RemoveScroll.defaultProps = {
61+
removeScrollBar: true,
62+
inert: false,
63+
};
64+
-RemoveScroll.classNames = {
65+
- fullWidth: fullWidthClassName,
66+
- zeroRight: zeroRightClassName,
67+
-};
68+
export { RemoveScroll };

test/e2e/meta.e2e.ts

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { expect, test } from '@playwright/test'
9+
10+
test('CSP headers', async ({ page }) => {
11+
// doesn't matter what page we go to
12+
const response = await page.goto('/')
13+
expect(response?.headers()).toMatchObject({
14+
// note nonce is represented as [0-9a-f]+
15+
'content-security-policy': expect.stringMatching(
16+
/^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'$/
17+
),
18+
'x-content-type-options': 'nosniff',
19+
'x-frame-options': 'DENY',
20+
})
21+
})

vercel.json

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
{
2-
"buildCommand": "API_MODE=msw npm run build && cp mockServiceWorker.js dist/",
2+
"buildCommand": "API_MODE=msw npm run build && cp mockServiceWorker.js dist/ && npx patch-package --reverse",
33
"outputDirectory": "dist",
4+
"headers": [
5+
{
6+
"source": "/(.*)",
7+
"headers": [
8+
{
9+
"key": "content-security-policy",
10+
"value": "default-src 'self'; style-src 'unsafe-inline' 'self'; frame-src 'none'; object-src 'none'; form-action 'none'; frame-ancestors 'none'"
11+
},
12+
{ "key": "x-content-type-options", "value": "nosniff" },
13+
{ "key": "x-frame-options", "value": "DENY" }
14+
]
15+
}
16+
],
417
"rewrites": [
518
{
619
"source": "/viewscript.js",

vite.config.ts

+27
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import { randomBytes } from 'crypto'
89
import { resolve } from 'path'
910
import basicSsl from '@vitejs/plugin-basic-ssl'
1011
import react from '@vitejs/plugin-react-swc'
@@ -13,6 +14,8 @@ import { createHtmlPlugin } from 'vite-plugin-html'
1314
import tsconfigPaths from 'vite-tsconfig-paths'
1415
import { z } from 'zod'
1516

17+
import vercelConfig from './vercel.json'
18+
1619
const ApiMode = z.enum(['msw', 'dogfood', 'nexus'])
1720

1821
const apiModeResult = ApiMode.default('nexus').safeParse(process.env.API_MODE)
@@ -67,6 +70,21 @@ const previewMetaTag = [
6770
},
6871
]
6972

73+
// vercel config is source of truth for headers
74+
const vercelHeaders = vercelConfig.headers[0].headers
75+
const headers = Object.fromEntries(vercelHeaders.map((h) => [h.key, h.value]))
76+
77+
// This is only needed for local dev to avoid breaking Vite's script injection.
78+
// Rather than use unsafe-inline all the time, the nonce approach is much more
79+
// narrowly scoped and lets us make sure everything *else* works fine without
80+
// unsafe-inline.
81+
const cspNonce = randomBytes(8).toString('hex')
82+
const csp = headers['content-security-policy']
83+
const devHeaders = {
84+
...headers,
85+
'content-security-policy': `${csp}; script-src 'nonce-${cspNonce}' 'self'`,
86+
}
87+
7088
// see https://vitejs.dev/config/
7189
export default defineConfig(({ mode }) => ({
7290
build: {
@@ -79,6 +97,8 @@ export default defineConfig(({ mode }) => ({
7997
app: 'index.html',
8098
},
8199
},
100+
// prevent inlining assets as `data:`, which is not permitted by our Content-Security-Policy
101+
assetsInlineLimit: 0,
82102
},
83103
define: {
84104
'process.env.MSW': JSON.stringify(apiMode === 'msw'),
@@ -99,8 +119,14 @@ export default defineConfig(({ mode }) => ({
99119
react(),
100120
apiMode === 'dogfood' && basicSsl(),
101121
],
122+
html: {
123+
// don't include a placeholder nonce in production.
124+
// use a CSP nonce in dev to avoid needing to permit 'unsafe-inline'
125+
cspNonce: mode === 'production' ? undefined : cspNonce,
126+
},
102127
server: {
103128
port: 4000,
129+
headers: devHeaders,
104130
// these only get hit when MSW doesn't intercept the request
105131
proxy: {
106132
'/v1': {
@@ -119,6 +145,7 @@ export default defineConfig(({ mode }) => ({
119145
},
120146
},
121147
},
148+
preview: { headers },
122149
test: {
123150
environment: 'jsdom',
124151
setupFiles: ['test/unit/setup.ts'],

0 commit comments

Comments
 (0)