fix: security improvements (#2593)

This commit is contained in:
Catalin Pit
2026-04-30 07:43:20 +03:00
committed by GitHub
parent 2f4c3893a3
commit ae497092d7
17 changed files with 324 additions and 40 deletions
+33
View File
@@ -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 };
};
+2 -1
View File
@@ -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);
+16
View File
@@ -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.
*/
+169
View File
@@ -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');
}
}
});