mirror of
https://github.com/documenso/documenso.git
synced 2026-07-02 01:01:00 +10:00
181 lines
6.9 KiB
TypeScript
181 lines
6.9 KiB
TypeScript
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|$)/;
|
|
|
|
/**
|
|
* Non-`/embed` page routes that customers iframe directly, plus the auth
|
|
* pages reachable from inside an embed iframe during the
|
|
* reauth-as-different-account flow.
|
|
*
|
|
* Signing routes (`/sign/:token`, `/d/:token`):
|
|
* Some customer integrations embed these URLs directly (without going
|
|
* through `EmbedSignDocument`). Without `frame-ancestors *` here, those
|
|
* integrations break with a "refused to connect" iframe error.
|
|
*
|
|
* Auth routes (`/signin`, `/forgot-password`, `/check-email`,
|
|
* `/unverified-account`):
|
|
* `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. The `(\/|\.data|$)` tail
|
|
* keeps `/sign` from matching `/signin`/`/signup` and `/d` from matching
|
|
* `/dashboard`.
|
|
*/
|
|
const FRAMEABLE_PATH_REGEX = /^\/(signin|forgot-password|check-email|unverified-account|sign|d)(\/|\.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' | 'frameable' | '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. Frameable (auth/signing)
|
|
// 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, signing, and auth routes are all reachable from inside a
|
|
// customer's iframe and therefore need `frame-ancestors *`. Every other
|
|
// page gets clickjacking protection.
|
|
if (kind === 'embed' || kind === 'frameable') {
|
|
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 (FRAMEABLE_PATH_REGEX.test(path)) {
|
|
return 'frameable';
|
|
}
|
|
|
|
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.
|
|
* - `frameable` — wildcard `frame-ancestors` only; needed because the
|
|
* embed reauth flow redirects the iframe to `/signin` etc,
|
|
* and because some customers iframe `/sign/:token` and
|
|
* `/d/:token` directly without using `EmbedSignDocument`.
|
|
* 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');
|
|
}
|
|
}
|
|
});
|