mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Every plugin iframe loaded /plugin-sandbox, a force-dynamic Next page. Each frame then fetched, parsed and hydrated Next's client router on top of React and the runtime, and the server rendered the page once per frame, one after another when a dozen plugins started together. Sandboxed frames have opaque origins, so the browser's code cache never helps, and they run on the host page's main thread: with 12 plugins the frames took 2.5-3 s to come up after the app had loaded. The sandbox routes now return a small HTML page with the UI kit's stylesheet and the runtime as an ES module, both built ahead of time: - `scripts/build-plugin-sandbox.mjs` bundles the runtime with esbuild and compiles `plugin-kit.css` with the app's Tailwind setup into `public/plugin-sandbox-assets/`, with a manifest. Named Tabler imports are pointed at the icons' own files, so the full icon set stays in a chunk that only loads for an icon name the kit doesn't ship. - `npm run build`, `npm run dev`, the Dockerfile and the perf-guard workflow run it before `next build`. - `lib/plugin-sandbox/sandbox-document.ts` writes the page; proxy.ts still sets the sandbox CSP and the script carries its nonce. - The assets are served from public/ with CORS and immutable caching. A frame on an idle page is ready in 255 ms instead of 362 ms, eleven at once in 1.0 s instead of 2.1 s.
430 lines
20 KiB
TypeScript
430 lines
20 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import createIntlMiddleware from "next-intl/middleware";
|
|
import { routing } from "./i18n/routing";
|
|
import { localeFromAcceptLanguage } from "./i18n/locale-matcher";
|
|
import { isSameOriginRequest } from "./lib/security/same-origin";
|
|
import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
|
|
import {
|
|
inlineAppFrameOrigins,
|
|
parseAppFrameOrigins,
|
|
pickAppFrameOriginsCookie,
|
|
} from "./lib/security/app-frame-origins";
|
|
import { configManager } from "./lib/admin/config-manager";
|
|
import { getWopiEditorOrigins } from "./lib/wopi/discovery";
|
|
import { detectSetupState } from "./lib/setup/state";
|
|
|
|
const intlMiddleware = createIntlMiddleware(routing);
|
|
|
|
/**
|
|
* next-intl uses best-fit locale matching, which can rank `zh` ahead of
|
|
* `zh-TW` for regional Traditional Chinese tags such as `zh-HK`. Collapse a
|
|
* Chinese Accept-Language list to the exact locale selected by Bulwark's
|
|
* script/region-aware matcher before handing the request to next-intl. URL
|
|
* prefixes and the NEXT_LOCALE cookie still keep their higher precedence.
|
|
*/
|
|
function withMatchedChineseAcceptLanguage(request: NextRequest): NextRequest {
|
|
const localeCookie = routing.localeCookie;
|
|
const localeCookieName =
|
|
localeCookie === false
|
|
? null
|
|
: typeof localeCookie === "object" && localeCookie.name
|
|
? localeCookie.name
|
|
: "NEXT_LOCALE";
|
|
const cookieLocale = localeCookieName ? request.cookies.get(localeCookieName)?.value : undefined;
|
|
if (cookieLocale && (routing.locales as readonly string[]).includes(cookieLocale)) return request;
|
|
|
|
const acceptLanguage = request.headers.get("accept-language");
|
|
const locale = localeFromAcceptLanguage(acceptLanguage, routing.locales);
|
|
if (locale !== "zh" && locale !== "zh-TW") return request;
|
|
if (acceptLanguage === locale) return request; // nothing to collapse, keep the original request
|
|
|
|
const headers = new Headers(request.headers);
|
|
headers.set("accept-language", locale);
|
|
// A cloned NextRequest re-parses its URL, so `nextConfig` has to be handed
|
|
// over as well: without it the base path stays glued to nextUrl.pathname and
|
|
// next-intl rewrites a sub-path install to /zh-TW/<basePath>/... (a 404)
|
|
// instead of /<basePath>/zh-TW/....
|
|
const basePath = request.nextUrl.basePath;
|
|
return new NextRequest(request, { headers, nextConfig: basePath ? { basePath } : undefined });
|
|
}
|
|
|
|
// Next 16's Proxy always runs on Node.js runtime and route-segment config
|
|
// (e.g. `export const config = { matcher }`) is no longer allowed in the
|
|
// proxy file. We replicate the previous matcher inline by short-circuiting
|
|
// requests for API routes, Next internals and static assets.
|
|
const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)/;
|
|
|
|
// What Next serves from public/: a single dotted segment at the root
|
|
// (/sw.js, /favicon.ico, /manifest.webmanifest) and the branding/,
|
|
// notification/, demo/ and plugin-sandbox-assets/ asset folders. The upstream matcher treated EVERY path whose
|
|
// last segment has a dot as a static file, but the [[...segments]] catch-alls
|
|
// under /<locale>/mail, /calendar, /contacts and /files make
|
|
// /en/mail/folder/inbox/statement.pdf a real signed-in page, and it rendered
|
|
// without a CSP or any other security header (GHSA-xvjh-v9c6-qcvc). Only what
|
|
// is genuinely static may skip locale routing and the headers.
|
|
const STATIC_ASSET_PATTERN = /^\/[^/]+\.[^/]+$|^\/(?:branding|notification|demo|plugin-sandbox-assets)\//;
|
|
|
|
export function isStaticAssetPath(pathname: string): boolean {
|
|
return STATIC_ASSET_PATTERN.test(pathname);
|
|
}
|
|
|
|
/**
|
|
* Hand the route every request header the client sent, plus `extra`.
|
|
*
|
|
* Next forwards ONLY the request headers listed in
|
|
* `x-middleware-override-headers` and deletes every other one. A bare
|
|
* NextResponse.next() carries no such list, so listing just x-nonce/x-pathname
|
|
* on it (as this proxy used to) stripped RSC, Next-Router-State-Tree,
|
|
* Next-Url, Cookie, Accept-Language, ... from every page request that skips
|
|
* the intl middleware: all locale-prefixed paths - i.e. every page of a
|
|
* NEXT_PUBLIC_LOCALE_PREFIX=always (Docker) build - plus /admin, /protocol,
|
|
* /setup, /connector and the plugin sandbox. Since Next 16.3 the server recomputes the
|
|
* `_rsc` cache-busting hash from those router headers
|
|
* (experimental.validateRSCRequestHeaders, on by default) and answers a
|
|
* mismatch with a 307 to the "expected" URL; the client re-requests, the
|
|
* headers are stripped again, and every navigation and link prefetch spun
|
|
* through a 307/200 loop for about a second - the old view stayed on screen
|
|
* for that long on each top-level switch and after login (#919).
|
|
*
|
|
* When the response already lists headers (the intl middleware's rewrite
|
|
* passes the full set), only `extra` is appended.
|
|
*/
|
|
function forwardRequestHeaders(
|
|
response: NextResponse,
|
|
request: NextRequest,
|
|
extra: Record<string, string>,
|
|
): NextResponse {
|
|
// A redirect renders no route, so there is nothing to forward to.
|
|
if (response.headers.has("location")) return response;
|
|
const listed = new Set(
|
|
(response.headers.get("x-middleware-override-headers") ?? "")
|
|
.split(",")
|
|
.map((name) => name.trim())
|
|
.filter(Boolean),
|
|
);
|
|
if (listed.size === 0) {
|
|
for (const [name, value] of request.headers) {
|
|
listed.add(name);
|
|
response.headers.set(`x-middleware-request-${name}`, value);
|
|
}
|
|
}
|
|
for (const [name, value] of Object.entries(extra)) {
|
|
listed.add(name);
|
|
response.headers.set(`x-middleware-request-${name}`, value);
|
|
}
|
|
response.headers.set("x-middleware-override-headers", Array.from(listed).join(","));
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* The path Next routes on. `nextUrl.pathname` keeps percent-escapes, but the
|
|
* route matcher decodes them, so `/api/%61uth/session` reaches the
|
|
* `/api/auth/session` handler. Security decisions must use the decoded form
|
|
* or an escaped spelling walks around them.
|
|
*/
|
|
export function routePathOf(pathname: string): string {
|
|
try {
|
|
return decodeURIComponent(pathname);
|
|
} catch {
|
|
return pathname;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Paths under /api/ that must accept requests without a same-origin browser
|
|
* context: the office editor's WOPI host calls the file endpoints
|
|
* server-to-server with a token of its own, not the session cookie.
|
|
*/
|
|
function isOriginGateExempt(routePath: string): boolean {
|
|
return routePath.startsWith("/api/wopi/files/");
|
|
}
|
|
|
|
/**
|
|
* frame-ancestors for the mail UI. It always includes 'self': the PDF preview
|
|
* frames a blob: document, and Safari enforces the page's frame-ancestors on
|
|
* that document too, so without 'self' the preview stays blank (#1301).
|
|
* 'none' in ALLOWED_FRAME_ANCESTORS means "no other site" here.
|
|
*/
|
|
export function mailFrameAncestors(allowed: string | undefined): string {
|
|
const sources = (allowed ?? "").trim().split(/\s+/).filter((s) => s && s.toLowerCase() !== "'none'");
|
|
if (!sources.some((s) => s.toLowerCase() === "'self'")) sources.unshift("'self'");
|
|
return sources.join(" ");
|
|
}
|
|
|
|
function isSetupPath(pathname: string): boolean {
|
|
return (
|
|
pathname === "/setup" ||
|
|
pathname.startsWith("/setup/") ||
|
|
pathname.startsWith("/api/setup")
|
|
);
|
|
}
|
|
|
|
export async function proxy(request: NextRequest) {
|
|
// Resolve setup state before deciding what to skip. The first call after
|
|
// boot triggers the config load; subsequent calls are in-memory.
|
|
await configManager.ensureLoaded();
|
|
const setupState = detectSetupState();
|
|
// Raw form for what is echoed back (x-pathname); decoded form for every
|
|
// decision about the request.
|
|
const rawPathname = request.nextUrl.pathname;
|
|
const pathname = routePathOf(rawPathname);
|
|
|
|
if (setupState === "bootstrap") {
|
|
// Wizard active. Redirect HTML pages to /setup; let asset/internal
|
|
// requests through so the wizard UI can render. Block non-setup APIs
|
|
// with a 503 so cached SPA code doesn't silently call them.
|
|
const allowed =
|
|
isSetupPath(pathname) ||
|
|
pathname === "/api/health" ||
|
|
pathname.startsWith("/_next/") ||
|
|
pathname.startsWith("/branding/") ||
|
|
// Public read endpoint - serves wizard-uploaded branding assets so
|
|
// image previews work during the wizard. No auth on the GET route.
|
|
pathname.startsWith("/api/admin/branding/") ||
|
|
isStaticAssetPath(pathname);
|
|
|
|
if (!allowed) {
|
|
if (pathname.startsWith("/api/")) {
|
|
return new NextResponse(
|
|
JSON.stringify({ error: "setup_required", message: "Initial setup has not completed." }),
|
|
{ status: 503, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
const url = request.nextUrl.clone();
|
|
url.pathname = "/setup";
|
|
url.search = request.nextUrl.search;
|
|
return NextResponse.redirect(url);
|
|
}
|
|
} else if (isSetupPath(pathname)) {
|
|
// Configured / env-managed: wizard is no longer reachable.
|
|
// - HTML /setup pages → redirect to admin login so users who reload
|
|
// the URL after setup don't see a dead "Not Found" page.
|
|
// - /api/setup/* → 404 (no reason to expose these endpoints).
|
|
if (pathname.startsWith("/api/setup")) {
|
|
return new NextResponse("Not Found", { status: 404 });
|
|
}
|
|
const url = request.nextUrl.clone();
|
|
url.pathname = "/admin/login";
|
|
url.search = "";
|
|
return NextResponse.redirect(url);
|
|
}
|
|
|
|
// Outer CSRF gate for every API route (GHSA-qvr9-m8cq-7wvg,
|
|
// GHSA-9mvj-98f5-9q6g). The identity cookies are SameSite=Lax, which a
|
|
// same-site sibling origin still receives, so any route that acts with
|
|
// them needs an origin check. The state-changing handlers check this
|
|
// themselves as well; this layer covers any route added later.
|
|
// GET/HEAD/OPTIONS pass through untouched.
|
|
if (pathname.startsWith("/api/") && !isOriginGateExempt(pathname) && !isSameOriginRequest(request)) {
|
|
return NextResponse.json({ error: "Cross-origin request rejected" }, { status: 403 });
|
|
}
|
|
|
|
if (PROXY_SKIP_PATTERN.test(pathname) || isStaticAssetPath(pathname)) {
|
|
// No page CSP here, but what comes back can still be a document: the
|
|
// not-found shell for a missing /foo.html or an unknown /api route. It
|
|
// must not be framed or sniffed into another type. API routes set their
|
|
// own CSP where they need one, so only non-API paths get frame-ancestors.
|
|
const skipped = NextResponse.next();
|
|
skipped.headers.set("X-Content-Type-Options", "nosniff");
|
|
skipped.headers.set("X-Frame-Options", "DENY");
|
|
if (!pathname.startsWith("/api/")) {
|
|
skipped.headers.set("Content-Security-Policy", "frame-ancestors 'none'");
|
|
}
|
|
return skipped;
|
|
}
|
|
|
|
const nonce = crypto.randomUUID();
|
|
const isDev = process.env.NODE_ENV === "development";
|
|
// The plugin-sandbox iframe document needs `'unsafe-eval'` to run plugin
|
|
// bundles via `new Function`. The untrusted route is null-origin
|
|
// (sandbox="allow-scripts"); the privileged route is same-origin
|
|
// (allow-same-origin) so a vetted plugin gets real WebCrypto + IndexedDB.
|
|
// Both get the SAME CSP relaxations (unsafe-eval, frame-ancestors 'self');
|
|
// the privileged route's extra power comes from the iframe sandbox flag the
|
|
// host sets, gated by signature + admin approval, NOT from a wider CSP.
|
|
const isSandboxPath =
|
|
pathname === "/plugin-sandbox" ||
|
|
pathname.startsWith("/plugin-sandbox/") ||
|
|
pathname === "/plugin-sandbox-privileged" ||
|
|
pathname.startsWith("/plugin-sandbox-privileged/");
|
|
|
|
// The sandbox routes are only ever loaded as iframes by the host bridge.
|
|
// Refuse them as a top-level document (window.open() from another site, a
|
|
// typed URL): the browser sets `Sec-Fetch-Dest` and page script cannot
|
|
// forge it; browsers without it fall through to the runtime's own host gate.
|
|
// They also disappear entirely while the plugin feature is off, so a stock
|
|
// install never serves a document carrying 'unsafe-eval'
|
|
// (GHSA-96cx-gx36-3g79). Other destinations pass - `iframe` for the bridge,
|
|
// `empty` for the router's own RSC refetches - and cross-site framing is
|
|
// already blocked by the `frame-ancestors 'self'` below.
|
|
if (isSandboxPath) {
|
|
const pluginsEnabled = configManager.getPolicy().features?.pluginsEnabled === true;
|
|
if (!pluginsEnabled || request.headers.get("sec-fetch-dest") === "document") {
|
|
return new NextResponse("The plugin sandbox is only served inside the webmail.", {
|
|
status: 403,
|
|
headers: { "content-type": "text/plain" },
|
|
});
|
|
}
|
|
}
|
|
|
|
const scriptSrc = isSandboxPath
|
|
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
|
|
: isDev
|
|
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
|
|
: `'self' 'nonce-${nonce}'`;
|
|
|
|
// A plugin reaches the network only through api.http.fetch, which the host
|
|
// checks against the manifest's httpOrigins (H-03). The sandbox document
|
|
// itself gets its own origin and nothing else, so fetch(), images, fonts
|
|
// and CSS url() can't carry data to a host the manifest didn't name. Its
|
|
// own origin stays because the router refetches the page as RSC.
|
|
const connectSrc = isSandboxPath
|
|
? `'self'`
|
|
: isDev ? `'self' http: https: ws: wss:` : `'self' https:`;
|
|
const imgSrc = isSandboxPath ? `'self' data: blob:` : `'self' data: blob: https:`;
|
|
const fontSrc = isSandboxPath ? `'self' data:` : `'self' https: data:`;
|
|
|
|
// The admin dashboard and the setup wizard are never framed. The embedding
|
|
// allowance is for the mail UI a portal wraps; a framed admin page is a
|
|
// clickjacking target with nothing to gain from being embedded.
|
|
const isAdminOrSetupPath = /^\/(?:admin|setup)(?:\/|$)/.test(pathname);
|
|
const frameAncestors = isSandboxPath
|
|
? `'self'`
|
|
: isAdminOrSetupPath
|
|
? "'none'"
|
|
: mailFrameAncestors(process.env.ALLOWED_FRAME_ANCESTORS);
|
|
|
|
// Plugins may declare iframe origins they need (e.g. for embedded video).
|
|
// Each origin is validated at install time and re-validated here.
|
|
const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
|
|
|
|
// Sidebar Apps set to open "inline" are a per-user setting the proxy can't
|
|
// read, so the client mirrors their origins into a cookie (#787). Ignored
|
|
// when the admin has turned the feature off, so a stale cookie can't keep
|
|
// widening the CSP after the fact.
|
|
const policy = configManager.getPolicy();
|
|
const sidebarAppsEnabled = policy.features?.sidebarAppsEnabled !== false;
|
|
const appFrameOrigins = sidebarAppsEnabled
|
|
? parseAppFrameOrigins(pickAppFrameOriginsCookie(
|
|
(name) => request.cookies.get(name)?.value,
|
|
request.nextUrl.protocol === "https:" || request.headers.get("x-forwarded-proto") === "https",
|
|
))
|
|
: [];
|
|
|
|
// Apps the operator pins for everyone (#931) are known server-side, so their
|
|
// origins go straight into the header - no cookie handshake, and no reload
|
|
// the first time a user opens one. They survive the gate above, which only
|
|
// governs the apps users add themselves.
|
|
const managedAppFrameOrigins = inlineAppFrameOrigins(policy.defaultSidebarApps);
|
|
|
|
// WOPI document editor (#425): the editor is launched by POSTing the access
|
|
// token into an iframe on its origin, so that origin must be allowed in both
|
|
// frame-src AND form-action (which is otherwise 'self'). The browser loads
|
|
// it from the origins the editor's discovery names, which are not that of
|
|
// wopiClientUrl when discovery is fetched over an internal address (#1130).
|
|
const wopiOrigins = await getWopiEditorOrigins();
|
|
|
|
const frameOrigins: string[] = [];
|
|
const seenFrameOrigins = new Set<string>();
|
|
for (const origin of [...pluginFrameOrigins, ...managedAppFrameOrigins, ...appFrameOrigins, ...wopiOrigins]) {
|
|
const key = origin.toLowerCase();
|
|
if (seenFrameOrigins.has(key)) continue;
|
|
seenFrameOrigins.add(key);
|
|
frameOrigins.push(origin);
|
|
}
|
|
|
|
const frameSrc =
|
|
frameOrigins.length > 0
|
|
? `frame-src 'self' blob: ${frameOrigins.join(" ")}`
|
|
: `frame-src 'self' blob:`;
|
|
|
|
const csp = [
|
|
`default-src 'self'`,
|
|
`script-src ${scriptSrc}`,
|
|
`style-src 'self' 'unsafe-inline'`,
|
|
`img-src ${imgSrc}`,
|
|
`font-src ${fontSrc}`,
|
|
`connect-src ${connectSrc}`,
|
|
frameSrc,
|
|
`object-src 'self' blob:`,
|
|
`base-uri 'self'`,
|
|
// Plugin frames may use <form> for its submit event (allow-forms), but a
|
|
// form must not post anywhere: that would be a way out past connect-src.
|
|
isSandboxPath ? `form-action 'none'` : `form-action ${["'self'", ...wopiOrigins].join(" ")}`,
|
|
`frame-ancestors ${frameAncestors}`,
|
|
`media-src 'self' blob:`,
|
|
].join("; ");
|
|
|
|
// Skip intl middleware for routes outside the localized app tree.
|
|
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
|
|
const isProtocolRoute = pathname === '/protocol' || pathname.startsWith('/protocol/');
|
|
const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/');
|
|
// Connector links (/connector/<target>) are published in docs and READMEs
|
|
// and must not carry a locale. Letting next-intl rewrite them to
|
|
// /en/connector/... 404s, which breaks every link already in the wild.
|
|
const isConnectorRoute = pathname === '/connector' || pathname.startsWith('/connector/');
|
|
// The plugin sandbox lives in its own root layout under app/(sandbox)/ and
|
|
// is not part of the localized tree. Letting next-intl rewrite the path to
|
|
// /en/plugin-sandbox 404s, which kills the iframe and disables every plugin.
|
|
const isSandboxRoute = isSandboxPath;
|
|
|
|
// When localePrefix is 'always', paths that already have a locale prefix
|
|
// (e.g. /en/settings) should not be re-processed by the intl middleware -
|
|
// doing so can trigger rewrite loops when combined with a proxy basePath.
|
|
const locales = routing.locales as readonly string[];
|
|
const hasLocalePrefix = locales.some(
|
|
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
|
|
);
|
|
|
|
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
|
|
if (
|
|
!isAdminRoute &&
|
|
!isProtocolRoute &&
|
|
!isSetupRoute &&
|
|
!isSandboxRoute &&
|
|
!isConnectorRoute &&
|
|
!hasLocalePrefix
|
|
) {
|
|
try {
|
|
intlResponse = intlMiddleware(withMatchedChineseAcceptLanguage(request));
|
|
} catch (error) {
|
|
console.error('Locale middleware error:', error);
|
|
}
|
|
}
|
|
const response = intlResponse ?? NextResponse.next();
|
|
|
|
// Expose the nonce AND the request pathname to server components as request
|
|
// headers. The root (main)/layout renders <html> ABOVE the [locale] segment,
|
|
// so getLocale() can't resolve the active locale there and falls back to the
|
|
// default - emitting <html lang="en"> on e.g. /de pages, which makes browsers
|
|
// offer to "translate this page". The layout reads x-pathname to recover it.
|
|
forwardRequestHeaders(response, request, { "x-nonce": nonce, "x-pathname": rawPathname });
|
|
|
|
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
|
|
// X-Frame-Options only supports DENY/SAMEORIGIN. When frame-ancestors
|
|
// specifies explicit origins, we rely solely on the CSP header.
|
|
if (frameAncestors === "'none'") {
|
|
response.headers.set("X-Frame-Options", "DENY");
|
|
} else if (frameAncestors === "'self'") {
|
|
response.headers.set("X-Frame-Options", "SAMEORIGIN");
|
|
}
|
|
|
|
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
response.headers.set("X-XSS-Protection", "0");
|
|
// Only on a request that arrived over TLS (directly or via the reverse
|
|
// proxy's X-Forwarded-Proto): a plain-http dev or LAN install must not be
|
|
// pinned to https by a header it cannot honour. No includeSubDomains, which
|
|
// would speak for the operator's other hosts.
|
|
if (request.nextUrl.protocol === "https:" || request.headers.get("x-forwarded-proto") === "https") {
|
|
response.headers.set("Strict-Transport-Security", "max-age=63072000");
|
|
}
|
|
response.headers.set(
|
|
"Permissions-Policy",
|
|
"camera=(), microphone=(), geolocation=(), payment=()"
|
|
);
|
|
response.headers.set("Content-Security-Policy", csp);
|
|
|
|
return response;
|
|
}
|