mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
fix: security improvements (#2593)
This commit is contained in:
@@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -23,10 +24,7 @@ import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signa
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZProfileFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: msg`Please enter a valid name.`.id }),
|
||||
name: ZNameSchema,
|
||||
signature: z.string().min(1, { message: msg`Signature Pad cannot be empty.`.id }),
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { z } from 'zod';
|
||||
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
@@ -39,10 +40,7 @@ import { UserProfileTimur } from '~/components/general/user-profile-timur';
|
||||
|
||||
export const ZSignUpFormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: msg`Please enter a valid name.`.id }),
|
||||
name: ZNameSchema,
|
||||
email: zEmail().min(1),
|
||||
password: ZPasswordSchema,
|
||||
signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }),
|
||||
@@ -60,7 +58,7 @@ export const ZSignUpFormSchema = z
|
||||
|
||||
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
|
||||
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
|
||||
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`,
|
||||
[AppErrorCode.ALREADY_EXISTS]: msg`We were unable to create your account. If you already have an account, try signing in instead.`,
|
||||
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export default async function handleRequest(
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
_loadContext: AppLoadContext,
|
||||
loadContext: AppLoadContext,
|
||||
) {
|
||||
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
|
||||
|
||||
@@ -30,6 +30,12 @@ export default async function handleRequest(
|
||||
|
||||
await dynamicActivate(language);
|
||||
|
||||
// Threaded into ServerRouter so React Router applies the nonce to the
|
||||
// scripts it injects (route manifest, hydration data, module preloads).
|
||||
// The same nonce is also exposed to the React tree via the root loader so
|
||||
// our own inline scripts/styles can carry it.
|
||||
const nonce = loadContext.nonce || undefined;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const userAgent = request.headers.get('user-agent');
|
||||
@@ -41,9 +47,10 @@ export default async function handleRequest(
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ServerRouter context={routerContext} url={request.url} />
|
||||
<ServerRouter context={routerContext} url={request.url} nonce={nonce} />
|
||||
</I18nProvider>,
|
||||
{
|
||||
nonce,
|
||||
[readyOption]() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
|
||||
+21
-8
@@ -27,6 +27,7 @@ import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||
import { langCookie } from './storage/lang-cookie.server';
|
||||
import { themeSessionResolver } from './storage/theme-session.server';
|
||||
import { appMetaTags } from './utils/meta';
|
||||
import { nonce } from './utils/nonce';
|
||||
|
||||
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
|
||||
|
||||
@@ -41,7 +42,7 @@ export function meta() {
|
||||
*/
|
||||
export const shouldRevalidate = () => false;
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
const session = await getOptionalSession(request);
|
||||
|
||||
const { getTheme } = await themeSessionResolver(request);
|
||||
@@ -67,6 +68,10 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
lang,
|
||||
theme: getTheme(),
|
||||
disableAnimations,
|
||||
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
|
||||
// SSR-rendered <script>/<style> elements in this layout (and child
|
||||
// routes that need it) can carry the matching nonce attribute.
|
||||
nonce: context.nonce,
|
||||
session: session.isAuthenticated
|
||||
? {
|
||||
user: session.user,
|
||||
@@ -95,8 +100,14 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
const { publicEnv, session, lang, disableAnimations, ...data } =
|
||||
useLoaderData<typeof loader>() || {};
|
||||
const {
|
||||
publicEnv,
|
||||
session,
|
||||
lang,
|
||||
disableAnimations,
|
||||
nonce: cspNonce,
|
||||
...data
|
||||
} = useLoaderData<typeof loader>() || {};
|
||||
|
||||
const [theme] = useTheme();
|
||||
|
||||
@@ -111,12 +122,13 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<Meta />
|
||||
<Links />
|
||||
<Links nonce={nonce(cspNonce)} />
|
||||
<meta name="google" content="notranslate" />
|
||||
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
|
||||
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} nonce={nonce(cspNonce)} />
|
||||
|
||||
{disableAnimations && (
|
||||
<style
|
||||
nonce={nonce(cspNonce)}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `*, *::before, *::after { animation: none !important; transition: none !important; }`,
|
||||
}}
|
||||
@@ -124,7 +136,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
|
||||
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
|
||||
<script>0</script>
|
||||
<script nonce={nonce(cspNonce)}>0</script>
|
||||
</head>
|
||||
<body>
|
||||
{/* Global license banner currently disabled. Need to wait until after a few releases. */}
|
||||
@@ -152,13 +164,14 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
</NuqsAdapter>
|
||||
|
||||
<script
|
||||
nonce={nonce(cspNonce)}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<ScrollRestoration nonce={nonce(cspNonce)} />
|
||||
<Scripts nonce={nonce(cspNonce)} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -17,19 +17,14 @@ import { EmbedRecipientExpired } from '~/components/embed/embed-recipient-expire
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
// Todo: (RR7) Test
|
||||
export function headers({ loaderHeaders }: Route.HeadersArgs) {
|
||||
const origin = loaderHeaders.get('Origin') ?? '*';
|
||||
|
||||
// Allow third parties to iframe the document.
|
||||
return {
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
'Content-Security-Policy': `frame-ancestors ${origin}`,
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
};
|
||||
}
|
||||
// Note: CSP (`frame-ancestors *`), `Referrer-Policy`, and
|
||||
// `X-Content-Type-Options` are now emitted globally by
|
||||
// `securityHeadersMiddleware` for any path under `/embed`. See
|
||||
// `apps/remix/server/security-headers.ts`.
|
||||
//
|
||||
// The previous `Access-Control-Allow-*` headers here only ever applied to
|
||||
// HTML page renders, where CORS preflight does not apply, so they were a
|
||||
// no-op and have been dropped along with the rest of `headers()`.
|
||||
|
||||
export function loader() {
|
||||
// SSR env variables.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Returns the supplied CSP nonce only when rendering on the server.
|
||||
*
|
||||
* Browsers strip the `nonce` attribute from `getAttribute()` after CSP
|
||||
* processing for security (so reflected XSS can't read the nonce back out
|
||||
* of the DOM), but React 18's hydration reads via `getAttribute` and warns
|
||||
* about a mismatch when the JSX prop is non-empty:
|
||||
*
|
||||
* Prop `nonce` did not match. Server: "" Client: "abc..."
|
||||
*
|
||||
* Returning `undefined` on the client makes React treat the prop as
|
||||
* "no attribute" — `shouldRemoveAttribute` short-circuits for nullish
|
||||
* values (see `react-dom/cjs/react-dom.development.js` `shouldRemoveAttribute`),
|
||||
* and the hydration prop-diff branch is skipped entirely.
|
||||
*
|
||||
* The nonce only matters at the moment the script/style is parsed by the
|
||||
* browser. After that it's an inert attribute, so dropping it on the
|
||||
* client has no functional impact. Subsequent dynamically-injected
|
||||
* scripts inherit trust via `'strict-dynamic'`.
|
||||
*/
|
||||
export const nonce = (value: string | undefined): string | undefined =>
|
||||
typeof window === 'undefined' ? value : '';
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getContext } from 'hono/context-storage';
|
||||
import type { AppLoadContext } from 'react-router';
|
||||
|
||||
import type { HonoEnv } from './router';
|
||||
import { CSP_NONCE_KEY } from './security-headers';
|
||||
|
||||
/**
|
||||
* Augment React Router's `AppLoadContext` so loaders, actions, and
|
||||
* `entry.server` can access fields by name without casts.
|
||||
*/
|
||||
declare module 'react-router' {
|
||||
interface AppLoadContext {
|
||||
/**
|
||||
* Per-request CSP nonce. Populated by `securityHeadersMiddleware` and surfaced here
|
||||
* so it can be threaded into `<ServerRouter nonce>` and root loader
|
||||
* data, which then feeds `<Scripts>`, `<Links>`, etc.
|
||||
*/
|
||||
nonce: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the React Router `AppLoadContext` for both dev (vite plugin) and
|
||||
* production (`hono-react-router-adapter/node`).
|
||||
*
|
||||
* The Hono context isn't passed directly by the adapter, so we read it via
|
||||
* `hono/context-storage`, which is enabled in `server/router.ts`.
|
||||
*/
|
||||
export const getLoadContext = (): AppLoadContext => {
|
||||
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
|
||||
|
||||
return { nonce };
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { serve } from '@hono/node-server';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import handle from 'hono-react-router-adapter/node';
|
||||
|
||||
import { getLoadContext } from './hono/server/load-context.js';
|
||||
import server from './hono/server/router.js';
|
||||
import * as build from './index.js';
|
||||
|
||||
@@ -28,7 +29,7 @@ server.use(
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = handle(build, server);
|
||||
const handler = handle(build, server, { getLoadContext });
|
||||
|
||||
const port = parseInt(process.env.PORT || '3000', 10);
|
||||
|
||||
|
||||
@@ -29,13 +29,20 @@ import { downloadRoute } from './api/download/download';
|
||||
import { filesRoute } from './api/files/files';
|
||||
import { type AppContext, appContext } from './context';
|
||||
import { appMiddleware } from './middleware';
|
||||
import { securityHeadersMiddleware } from './security-headers';
|
||||
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
|
||||
import { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
|
||||
|
||||
// Re-export so the rollup build (entry: server/router.ts) bundles
|
||||
// load-context.ts. server/main.js imports getLoadContext from the rolled-up
|
||||
// output to wire it into the React Router adapter.
|
||||
export { getLoadContext } from './load-context';
|
||||
|
||||
export interface HonoEnv {
|
||||
Variables: RequestIdVariables & {
|
||||
context: AppContext;
|
||||
logger: Logger;
|
||||
cspNonce: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +63,15 @@ const fileRateLimitMiddleware = createRateLimitMiddleware(fileUploadRateLimit);
|
||||
app.use(contextStorage());
|
||||
app.use(appContext);
|
||||
|
||||
/**
|
||||
* Emit response security headers (CSP with per-request nonce, plus
|
||||
* Referrer-Policy and X-Content-Type-Options on embed routes). Must run
|
||||
* after `contextStorage()` so the nonce is readable via `getContext()` from
|
||||
* `getLoadContext`, and before the React Router handler so the response
|
||||
* carries the header.
|
||||
*/
|
||||
app.use(securityHeadersMiddleware);
|
||||
|
||||
/**
|
||||
* RR7 app middleware.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
|
||||
import type { HonoEnv } from './router';
|
||||
|
||||
/**
|
||||
* Paths that never render HTML and therefore do not need security headers.
|
||||
*
|
||||
* Browsers ignore CSP and friends on non-document responses, so we skip
|
||||
* them to keep API/manifest/asset responses clean.
|
||||
*/
|
||||
const NON_PAGE_PATH_REGEX = /^(\/api\/|\/ingest\/|\/__manifest|\/assets\/|\/apple-.*|\/favicon.*)/;
|
||||
|
||||
/**
|
||||
* Embed routes serve our white-label embed UI. Customers iframe these from
|
||||
* arbitrary origins, so `frame-ancestors` must be wildcard, and customer-
|
||||
* supplied CSS is injected at runtime as `<style>` elements which means
|
||||
* `style-src-elem` cannot be nonce-restricted on these routes.
|
||||
*/
|
||||
const EMBED_PATH_REGEX = /^\/embed(\/|\.data|$)/;
|
||||
|
||||
/**
|
||||
* Auth pages reachable from inside an embed iframe during the
|
||||
* reauth-as-different-account flow.
|
||||
*
|
||||
* `apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx`
|
||||
* does `window.location.href = '/signin?...'` inside the iframe when the
|
||||
* user needs to sign out and sign back in as a different account, and
|
||||
* `<SignInForm>` links/navigates to `/forgot-password`, `/check-email`, and
|
||||
* `/unverified-account` from there.
|
||||
*
|
||||
* Without `frame-ancestors *` on these routes, the customer's iframe gets
|
||||
* blocked the moment the user clicks "Login" in the reauth dialog.
|
||||
*
|
||||
* These routes still get the strict nonced `script-src`/`style-src-elem`
|
||||
* policy — only `frame-ancestors` is relaxed.
|
||||
*/
|
||||
const AUTH_FRAMEABLE_PATH_REGEX =
|
||||
/^\/(signin|forgot-password|check-email|unverified-account)(\/|\.data|$)/;
|
||||
|
||||
/**
|
||||
* Hono context variable name where the per-request CSP nonce is stashed.
|
||||
*
|
||||
* Read by `getLoadContext` (server/load-context.ts) so the nonce can be
|
||||
* threaded into React Router's `<ServerRouter nonce>` and surfaced in the
|
||||
* root loader for use by `<Scripts>`, `<Links>`, etc.
|
||||
*/
|
||||
export const CSP_NONCE_KEY = 'cspNonce' as const;
|
||||
|
||||
const generateNonce = () => {
|
||||
const buf = new Uint8Array(16);
|
||||
crypto.getRandomValues(buf);
|
||||
|
||||
let binary = '';
|
||||
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
binary += String.fromCharCode(buf[i]);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
type CspPathKind = 'embed' | 'auth' | 'default';
|
||||
|
||||
const buildCspHeader = ({ nonce, kind }: { nonce: string; kind: CspPathKind }) => {
|
||||
// `'self'` is included alongside `'strict-dynamic'` as a fallback for
|
||||
// browsers that don't understand `'strict-dynamic'`. Modern browsers
|
||||
// ignore `'self'` (and other host/scheme sources) when `'strict-dynamic'`
|
||||
// is present.
|
||||
const directives = [
|
||||
`base-uri 'self'`,
|
||||
`object-src 'none'`,
|
||||
`form-action 'self'`,
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
||||
// PDF.js (apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx)
|
||||
// creates a Web Worker via `new Worker(url)`. `'strict-dynamic'` does
|
||||
// not reliably propagate to worker creation across browsers, and
|
||||
// without `worker-src` the browser falls back to `script-src` which
|
||||
// would block the worker. `blob:` covers libs that inline workers.
|
||||
`worker-src 'self' blob:`,
|
||||
// Inline `style=""` attributes cannot be nonced or hashed (CSP3 has no
|
||||
// mechanism for it), and React inline styles, framer-motion, react-rnd,
|
||||
// konva, etc. all rely on them. `'unsafe-inline'` for attributes is
|
||||
// industry standard and does not weaken `style-src-elem`.
|
||||
`style-src-attr 'unsafe-inline'`,
|
||||
];
|
||||
|
||||
// Embeds inject customer-supplied CSS via runtime-created `<style>`
|
||||
// elements (see apps/remix/app/utils/css-vars.ts). Nonce-stamping those
|
||||
// would be brittle for white-label customers, so we accept
|
||||
// `'unsafe-inline'` on the embed scope only. Auth pages do NOT load
|
||||
// customer CSS and keep the strict nonced policy.
|
||||
if (kind === 'embed') {
|
||||
directives.push(`style-src-elem 'self' 'unsafe-inline'`);
|
||||
} else {
|
||||
directives.push(`style-src-elem 'self' 'nonce-${nonce}'`);
|
||||
}
|
||||
|
||||
// Embed and auth routes are both reachable from inside a customer's
|
||||
// iframe and therefore need `frame-ancestors *`. Every other page gets
|
||||
// clickjacking protection.
|
||||
if (kind === 'embed' || kind === 'auth') {
|
||||
directives.push(`frame-ancestors *`);
|
||||
} else {
|
||||
directives.push(`frame-ancestors 'self'`);
|
||||
}
|
||||
|
||||
return directives.join('; ');
|
||||
};
|
||||
|
||||
const classifyPath = (path: string): CspPathKind => {
|
||||
if (EMBED_PATH_REGEX.test(path)) {
|
||||
return 'embed';
|
||||
}
|
||||
|
||||
if (AUTH_FRAMEABLE_PATH_REGEX.test(path)) {
|
||||
return 'auth';
|
||||
}
|
||||
|
||||
return 'default';
|
||||
};
|
||||
|
||||
/**
|
||||
* Owns response security headers for page responses:
|
||||
* `Content-Security-Policy`, plus `Referrer-Policy` and
|
||||
* `X-Content-Type-Options` on embed routes (preserved from the per-route
|
||||
* `headers()` export this middleware replaces).
|
||||
*
|
||||
* Generates a per-request CSP nonce and stashes it on the Hono context so
|
||||
* `getLoadContext` (server/load-context.ts) can thread it into React
|
||||
* Router for `<ServerRouter nonce>` and `<Scripts nonce>` etc.
|
||||
*
|
||||
* Path-aware classification:
|
||||
* - `embed` — wildcard `frame-ancestors`, `'unsafe-inline'` style-src-elem
|
||||
* (white-label CSS injection), strict nonced script-src.
|
||||
* - `auth` — wildcard `frame-ancestors` only; needed because the embed
|
||||
* reauth flow redirects the iframe to `/signin` etc. Strict
|
||||
* nonced script-src and style-src-elem otherwise.
|
||||
* - default — strict nonced script-src and style-src-elem,
|
||||
* `frame-ancestors 'self'` for clickjacking protection.
|
||||
*/
|
||||
export const securityHeadersMiddleware = createMiddleware<HonoEnv>(async (c, next) => {
|
||||
const nonce = generateNonce();
|
||||
|
||||
c.set(CSP_NONCE_KEY, nonce);
|
||||
|
||||
await next();
|
||||
|
||||
const path = c.req.path;
|
||||
|
||||
if (NON_PAGE_PATH_REGEX.test(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = classifyPath(path);
|
||||
|
||||
c.res.headers.set('Content-Security-Policy', buildCspHeader({ nonce, kind }));
|
||||
|
||||
// Preserved from the per-route `headers()` export in
|
||||
// apps/remix/app/routes/embed+/_v0+/_layout.tsx, which has been removed.
|
||||
if (kind === 'embed') {
|
||||
if (!c.res.headers.has('Referrer-Policy')) {
|
||||
c.res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
}
|
||||
|
||||
if (!c.res.headers.has('X-Content-Type-Options')) {
|
||||
c.res.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -47,6 +47,10 @@ export default defineConfig({
|
||||
tsconfigPaths(),
|
||||
serverAdapter({
|
||||
entry: 'server/router.ts',
|
||||
getLoadContext: async () => {
|
||||
const { getLoadContext } = await import('./server/load-context');
|
||||
return getLoadContext();
|
||||
},
|
||||
exclude: [
|
||||
// Spread the defaults but replace the /.css$/ rule so that Bull
|
||||
// Board's static CSS at /api/jobs/board/static/** passes through to Hono.
|
||||
|
||||
Reference in New Issue
Block a user