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//... (a 404) // instead of //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/ and demo/ asset folders. The upstream matcher treated EVERY path whose // last segment has a dot as a static file, but the [[...segments]] catch-alls // under //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)\//; 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, ): 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/"); } 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'" : process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'"; // 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(); 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
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/) 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 | 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 ABOVE the [locale] segment, // so getLocale() can't resolve the active locale there and falls back to the // default - emitting 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"); } response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); response.headers.set("X-XSS-Protection", "0"); response.headers.set( "Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()" ); response.headers.set("Content-Security-Policy", csp); return response; }