diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 76ac360c4..f94e8fbfc 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,5 +1,6 @@ import { PassThrough } from 'node:stream' import { styleText } from 'node:util' +import { contentSecurity } from '@nichtsam/helmet/content' import { createReadableStreamFromReadable } from '@react-router/node' import * as Sentry from '@sentry/node' import { isbot } from 'isbot' @@ -20,6 +21,8 @@ export const streamTimeout = 5000 init() global.ENV = getEnv() +const MODE = process.env.NODE_ENV ?? 'development' + type DocRequestArgs = Parameters export default async function handleRequest(...args: DocRequestArgs) { @@ -64,6 +67,33 @@ export default async function handleRequest(...args: DocRequestArgs) { const body = new PassThrough() responseHeaders.set('Content-Type', 'text/html') responseHeaders.append('Server-Timing', timings.toString()) + + contentSecurity(responseHeaders, { + crossOriginEmbedderPolicy: false, + contentSecurityPolicy: { + // NOTE: Remove reportOnly when you're ready to enforce this CSP + reportOnly: true, + directives: { + fetch: { + 'connect-src': [ + MODE === 'development' ? 'ws:' : undefined, + process.env.SENTRY_DSN ? '*.sentry.io' : undefined, + "'self'", + ], + 'font-src': ["'self'"], + 'frame-src': ["'self'"], + 'img-src': ["'self'", 'data:'], + 'script-src': [ + "'strict-dynamic'", + "'self'", + `'nonce-${nonce}'`, + ], + 'script-src-attr': [`'nonce-${nonce}'`], + }, + }, + }, + }) + resolve( new Response(createReadableStreamFromReadable(body), { headers: responseHeaders, diff --git a/package-lock.json b/package-lock.json index 8236d3db7..c08d56bad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@epic-web/totp": "^3.0.0", "@mjackson/form-data-parser": "^0.7.0", "@nasa-gcn/remix-seo": "^2.0.1", + "@nichtsam/helmet": "0.3.0", "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@paralleldrive/cuid2": "^2.2.2", @@ -52,7 +53,6 @@ "express-rate-limit": "^7.5.0", "get-port": "^7.1.0", "glob": "^11.0.1", - "helmet": "^8.0.0", "input-otp": "^1.4.2", "intl-parse-accept-language": "^1.0.0", "isbot": "^5.1.22", @@ -1809,6 +1809,15 @@ "@remix-run/server-runtime": "^1.0.0 || ^2.0.0" } }, + "node_modules/@nichtsam/helmet": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@nichtsam/helmet/-/helmet-0.3.0.tgz", + "integrity": "sha512-eXpehik+AGZu9FG5fRI3IAznt+NjJ/zKDGQCXQL9QLvfk1dncVpI2VBpvEv6G1rZ49WVHkxPKzuX40F5T6V4SA==", + "license": "MIT", + "engines": { + "node": "22" + } + }, "node_modules/@noble/hashes": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.1.tgz", @@ -9723,15 +9732,6 @@ "dev": true, "license": "MIT" }, - "node_modules/helmet": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.0.0.tgz", - "integrity": "sha512-VyusHLEIIO5mjQPUI1wpOAEu+wl6Q0998jzTxqUYGE45xCIcAxy3MsbEK/yyJUJ3ADeMoB6MornPH6GMWAf+Pw==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", diff --git a/package.json b/package.json index 6aef98721..d5cfca5ff 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@epic-web/totp": "^3.0.0", "@mjackson/form-data-parser": "^0.7.0", "@nasa-gcn/remix-seo": "^2.0.1", + "@nichtsam/helmet": "0.3.0", "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@paralleldrive/cuid2": "^2.2.2", @@ -89,7 +90,6 @@ "express-rate-limit": "^7.5.0", "get-port": "^7.1.0", "glob": "^11.0.1", - "helmet": "^8.0.0", "input-otp": "^1.4.2", "intl-parse-accept-language": "^1.0.0", "isbot": "^5.1.22", diff --git a/server/index.ts b/server/index.ts index b6db8975a..7bf8cc655 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto' import { styleText } from 'node:util' +import { helmet } from '@nichtsam/helmet/node-http' import { createRequestHandler } from '@react-router/express' import * as Sentry from '@sentry/node' import { ip as ipAddress } from 'address' @@ -8,7 +9,6 @@ import compression from 'compression' import express from 'express' import rateLimit from 'express-rate-limit' import getPort, { portNumbers } from 'get-port' -import helmet from 'helmet' import morgan from 'morgan' import { type ServerBuild } from 'react-router' @@ -68,6 +68,12 @@ app.use(compression()) // http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header app.disable('x-powered-by') +app.use((_, res, next) => { + // The referrerPolicy breaks our redirectTo logic + helmet(res, { general: { referrerPolicy: false } }) + next() +}) + if (viteDevServer) { app.use(viteDevServer.middlewares) } else { @@ -110,39 +116,6 @@ app.use((_, res, next) => { next() }) -app.use( - helmet({ - xPoweredBy: false, - referrerPolicy: { policy: 'same-origin' }, - crossOriginEmbedderPolicy: false, - contentSecurityPolicy: { - // NOTE: Remove reportOnly when you're ready to enforce this CSP - reportOnly: true, - directives: { - 'connect-src': [ - MODE === 'development' ? 'ws:' : null, - process.env.SENTRY_DSN ? '*.sentry.io' : null, - "'self'", - ].filter(Boolean), - 'font-src': ["'self'"], - 'frame-src': ["'self'"], - 'img-src': ["'self'", 'data:'], - 'script-src': [ - "'strict-dynamic'", - "'self'", - // @ts-expect-error - (_, res) => `'nonce-${res.locals.cspNonce}'`, - ], - 'script-src-attr': [ - // @ts-expect-error - (_, res) => `'nonce-${res.locals.cspNonce}'`, - ], - 'upgrade-insecure-requests': null, - }, - }, - }), -) - // When running tests or running in development, we want to effectively disable // rate limiting because playwright tests are very fast and we don't want to // have to wait for the rate limit to reset between tests.