// Next 16 renamed middleware.ts → proxy.ts. Three jobs: // 1. Host → managed app: on a managed-app host (hermes.os.…, openclaw.os.…, from // NEXT_PUBLIC_MANAGED_APP_HOST_TEMPLATE) EVERY path is rewritten into that // app's proxy route, so the dashboard is root-mounted at "/" on its own origin // and the cockpit — pages, /api/v1/exec, /api/auth/* — does not exist there. // That gating is what makes the widened session cookie (Domain=mso.example.com) // safe to hand to those hosts. // 2. CSRF depth-2 for mutating /api — every /api route already verifies the // signed session cookie via requireSession(); this adds the second factor // (mutating /api must come from our own origin), because those endpoints are // literal host shell and the SameSite=Strict cookie is a single point. // 3. A per-request nonce + Content-Security-Policy on the HTML document. The app // is PUBLIC and renders untrusted host content (fs bytes, terminal output), // so a strict script-src is the XSS→host-RCE containment layer. Next reads // the nonce from the REQUEST content-security-policy header and auto-nonces // its own bootstrap + RSC inline scripts; x-nonce is our channel for the // hand-written theme-noflash script in app/layout.tsx. import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { MANAGED_APP_HOST_HEADER, isUnclaimedAppNamespaceHost, managedAppIdForHost, } from "@/lib/managed-apps/origin"; import { proxyPrefix, upstreamSocketHeaders, } from "@/lib/managed-apps/proxy-headers"; import { projectIngressDecision, projectIngressHeaders } from "@/lib/managed-apps/project-ingress"; import { camoufoxViewerCsp, isCamoufoxViewerHost } from "@/lib/camoufox/origin"; import { camoufoxViewerAuthorized, camoufoxViewerUpstreamPath, gateCamoufoxViewer, } from "@/lib/camoufox/viewer-gate"; import { applyPrivatePageCachePolicy } from "@/lib/auth/page-cache"; import { gateManagedApp } from "@/lib/managed-apps/session"; import { sessionAppForHost, shellAppTarget, shellAppUpstreamHeaders } from "@/lib/surfaces/session"; import { gateShellApp, shellAppResponseHeaders } from "@/lib/surfaces/session-gate"; import { requestBodyLimit } from "@/scripts/server-body-policy.mjs"; import { IS_DEMO } from "@/lib/demo"; const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]); // `Upgrade` is a comma-separated token list, so a substring test would also match // a header naming some other protocol. const WEBSOCKET_UPGRADE = /(?:^|,)\s*websocket\s*(?:,|$)/i; // Mirrors SESSION_COOKIE in lib/auth/require-session.ts. Not imported from there: // that module pulls in next/headers, which middleware cannot use. // Only loopback. dashboardUrl comes from HERMES_DASHBOARD_URL / OPENCLAW_DASHBOARD_URL, // and the upgrade hop below is the one place a rewrite leaves this server — an env // typo pointing it off-box would turn the cockpit into an open socket relay. const LOOPBACK_HOST = /^(?:127\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[::1\]|::1|localhost)$/i; // noVNC + websockify for the Camoufox browser session (scripts/camoufox-vnc-service // serves it). Loopback-checked at the rewrite, same as the managed-app dashboards. const CAMOUFOX_NOVNC_URL = process.env.CAMOUFOX_NOVNC_URL ?? "http://127.0.0.1:6080"; // Paths the matcher used to exclude. A matcher cannot exclude by HOST and /_next/* // must be blockable on an app host, so they reach middleware now and are waved // through here instead — identical outcome on the cockpit (no nonce, no policy, no // per-asset work). Prefix semantics kept exactly as the old negative lookahead's. const MATCHER_EXCLUDED = /^\/(?:_next\/static|_next\/image|favicon\.ico)/; function blocked() { return NextResponse.json({ error: "cross_origin_blocked" }, { status: 403 }); } // Nothing but that app's own surface exists on an app host, and the app is not // Next: no dashboard serves /_next/*, it is only ever the cockpit's namespace. function notFound() { return new NextResponse("Not Found", { status: 404, headers: { "content-type": "text/plain; charset=utf-8" }, }); } // Extracted so the app-host branch can apply the SAME rule: after its rewrite the // path starts with /api/, i.e. past the point where this gate would have run. function crossOriginMutation(request: NextRequest): boolean { // Sec-Fetch-Site is a forbidden header — when present it is authoritative. const site = request.headers.get("sec-fetch-site"); if (site) return site !== "same-origin" && site !== "none"; // Older/non-browser clients: Origin host match, else require the cookie. const origin = request.headers.get("origin"); if (origin) { const expected = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.host; try { return new URL(origin).host !== expected; } catch { return true; } } return !request.cookies.get("session"); } // Per-directive rationale (verified against real usage): // - script-src: nonce (Next bootstrap/RSC/theme) + strict-dynamic (nonced // bootstrap pulls /_next chunks) + wasm-unsafe-eval (@imgly onnxruntime WASM). // - style-src 'unsafe-inline': radix/konva/react + next/font inline styles; // ignored for scripts once a nonce is present (CSP3), so no XSS weakening. // - img-src https:: quicklink favicons hit www.google.com directly + the stock // picker renders arbitrary Openverse/Unsplash hosts + "paste any image URL". // - connect-src stays TIGHT (self + @imgly host): all AI/BYOK/stock/oauth fetches // are SERVER-side, so the browser only hits same-origin /api — the exfil gate. // - frame-src https:: media-viewer PDF + widget "Embed" + app-store runtime iframes // load external URLs (each sandboxed). worker/child blob: = SW + onnx worker. function contentSecurityPolicy(nonce: string): string { return [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'wasm-unsafe-eval'`, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https:", "font-src 'self' data:", "connect-src 'self' https://staticimgly.com blob: data:", "worker-src 'self' blob:", "child-src 'self' blob:", "media-src 'self' blob: data:", "frame-src 'self' https: blob: data:", "manifest-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", ].join("; "); } export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; const contentLength = request.headers.get("content-length"); if (contentLength && Number(contentLength) > requestBodyLimit(pathname)) return NextResponse.json({ error: "request_too_large" }, { status: 413 }); // Which app host this is, if any. The HOST header is authoritative and // x-forwarded-host is deliberately NOT consulted here: Traefik routes on Host // with passHostHeader:true, so Host is the public name the router already // matched, while x-forwarded-host is a second, client-settable name for the same // thing. Letting the two disagree is the whole risk — a request on an app host // that could claim the cockpit host would escape the rewrite below and reach // /api/v1/exec with a session cookie that is now sent to that host. The opposite // lie (claiming an app host while on the cockpit) only restricts the request. const host = request.headers.get("host") ?? request.nextUrl.host; const managedApp = managedAppIdForHost(host); const sessionApp = !IS_DEMO && await sessionAppForHost(host); if (sessionApp && !managedApp && !isCamoufoxViewerHost(host)) { if (WEBSOCKET_UPGRADE.test(request.headers.get("upgrade") ?? "")) return notFound(); if (pathname === "/_next" || pathname.startsWith("/_next/")) return notFound(); if (MUTATING.has(request.method) && request.headers.get("origin") !== sessionApp.origin) return blocked(); const gate = await gateShellApp(request, sessionApp); if (gate) return gate; const result = NextResponse.rewrite(shellAppTarget(sessionApp, request.nextUrl), { request: { headers: shellAppUpstreamHeaders(request.headers, sessionApp) }, }); for (const [name, value] of Object.entries(shellAppResponseHeaders())) result.headers.set(name, value); return result; } // noVNC gets one reserved split-origin host. The cockpit session cookie is // intentionally NOT shared with a sibling viewer hostname. Instead, an authenticated // operator obtains a 60-second ticket on the cockpit, carries it only in the URL // fragment, and exchanges it here for a host-only HttpOnly viewer cookie. if (isCamoufoxViewerHost(host)) { if (WEBSOCKET_UPGRADE.test(request.headers.get("upgrade") ?? "")) return notFound(); const viewerGate = await gateCamoufoxViewer(request, pathname); if (!camoufoxViewerAuthorized(viewerGate)) return viewerGate; if (request.method !== "GET" && request.method !== "HEAD") return notFound(); let base: URL; try { base = new URL(CAMOUFOX_NOVNC_URL); } catch { return notFound(); } if ( (base.protocol !== "http:" && base.protocol !== "https:") || !LOOPBACK_HOST.test(base.hostname) ) { return notFound(); } const upstreamPath = camoufoxViewerUpstreamPath(pathname); if (!upstreamPath) return notFound(); const target = new URL(base); target.pathname = upstreamPath; target.search = request.nextUrl.search; const response = NextResponse.rewrite(target, { request: { headers: upstreamSocketHeaders(request.headers) }, }); response.headers.set("Content-Security-Policy", camoufoxViewerCsp()); response.headers.set( "Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=()", ); response.headers.set("Referrer-Policy", "no-referrer"); response.headers.set("X-Content-Type-Options", "nosniff"); response.headers.set("Cross-Origin-Resource-Policy", "same-origin"); response.headers.set("Cache-Control", "no-store, no-transform"); response.headers.set("CDN-Cache-Control", "no-store"); response.headers.set("Cloudflare-CDN-Cache-Control", "no-store"); response.headers.delete("X-Frame-Options"); return response; } // Optional project integrations may expose ONE exact machine-to-machine POST // lane on a managed-app host. Stock MSO has no routes because // OS_PROJECT_INGRESS_ROUTES defaults to empty. The route config is fixed by the // operator, targets loopback only, and the upstream still verifies the real // HMAC secret; this edge check only rejects obvious public garbage before CSRF. const ingress = projectIngressDecision(request, managedApp, pathname); if (ingress.matched) { if (!ingress.target) return blocked(); return NextResponse.rewrite(new URL(ingress.target), { request: { headers: projectIngressHeaders(request.headers) } }); } // A host inside the app namespace that is NOT an app (a new `X.mso.example.com` // record, or a `*.os` wildcard) must not serve the cockpit: the session cookie is // widened to that name, so such a host would be same-origin with a fully // authenticated cockpit — the reach the split exists to remove, re-opened by a DNS // edit alone. The parent itself (the cockpit) is never matched, so this cannot // lock the operator out. if (!managedApp && isUnclaimedAppNamespaceHost(host)) return notFound(); if (managedApp) { // Before the rewrite, or it never runs. Legitimate traffic is unaffected: the // frame's own fetch/XHR and its form posts are same-origin with the app host. // Cockpit JS reaching an app host is only SAME-SITE, so it is blocked — and // that reach is exactly what the origin split exists to remove. if (MUTATING.has(request.method) && crossOriginMutation(request)) return blocked(); if (pathname === "/_next" || pathname.startsWith("/_next/")) return notFound(); // The production server owns upgrades and validates live authority in both // directions. Stock Next rewrites cannot revoke an already-open socket. if (WEBSOCKET_UPGRADE.test(request.headers.get("upgrade") ?? "")) return notFound(); const gate = await gateManagedApp(request, managedApp); if (gate) return gate; const headers = new Headers(request.headers); // set(), not append(): this overwrites whatever copy the client sent. headers.set(MANAGED_APP_HOST_HEADER, managedApp); // No trailing slash: "/" must become the prefix itself. Next 308-redirects a // trailing slash away before middleware even runs, and re-adding one here would // send that redirect back to this host to be rewritten a second time, one prefix // deeper. Two traps in building this URL: // • use nextUrl.origin EXACTLY. A reverse proxy sets x-forwarded-proto, so Next // describes its own plain-HTTP listener with a synthetic https://loopback origin // (verified on Next 16.3.8: resolve-routes getRelativeURL compares the rewrite // origin against initUrl by exact equality). Any other origin — e.g. downgrading // the loopback scheme to match the socket — is classified EXTERNAL and self-hopped // over the network against the public Host, where middleware re-runs as a non-app // host, strips the marker below, and 404s the proxy route. // • a plain URL, not nextUrl.clone(): NextURL remembers the incoming trailing // slash and puts it back when the pathname is reassigned. const path = `${proxyPrefix(managedApp)}${pathname.replace(/\/+$/, "")}`; return NextResponse.rewrite( new URL(`${path}${request.nextUrl.search}`, request.nextUrl.origin), { request: { headers }, }, ); } if (MATCHER_EXCLUDED.test(pathname)) return NextResponse.next(); // Machine-to-machine protocol surfaces are JSON/SSE, not HTML, and are reached // cross-origin by design. They live outside /api deliberately: browser-cookie // CSRF is the wrong control because MCP and inbound A2A authenticate with explicit // bearer credentials that a browser never attaches on its own. The A2A Agent Card // is public discovery metadata, while /a2a/v1 verifies its bearer before dispatch. // Treat all of them like /api for response policy: no nonce, no document CSP. // NOTE /oauth/authorize is NOT here on purpose — it is a real HTML consent page // and must keep the nonce + document CSP. Only machine protocol endpoints are listed. const isMcp = pathname === "/mcp" || pathname.startsWith("/.well-known/oauth-") || pathname === "/oauth/token" || pathname === "/oauth/register"; const isA2A = pathname === "/a2a/v1" || pathname === "/.well-known/agent-card.json"; const isMachineProtocol = isMcp || isA2A; // Workflow webhook triggers are intentionally inbound machine endpoints. Their // own route enforces method, rate, body bounds, idempotency and optional bearer // auth; browser-cookie CSRF proof would make legitimate external webhooks // impossible and is not their authentication authority. const isWorkflowWebhook = pathname.startsWith("/api/v1/workflows/webhook/"); const isApi = pathname.startsWith("/api/") || isMachineProtocol || (["/integrations", "/integrations/manager", "/integrations/embed"].includes(pathname) && ["GET", "HEAD"].includes(request.method)); // The historical same-origin bridge is permanently closed. noVNC is executable // third-party code and is served only from its reserved split-origin host above. if (pathname.startsWith("/camoufox-vnc/")) return notFound(); // CSRF depth-2 for mutating owner/browser APIs. Machine protocols are excluded: // their explicit bearer authentication is authoritative, while same-origin proof // would break legitimate remote MCP/A2A clients. /api/v1/a2a stays protected // because it is the owner management API, not the A2A protocol endpoint. if ( MUTATING.has(request.method) && isApi && !isMachineProtocol && pathname !== "/api/internal/socket-policy" && !isWorkflowWebhook && pathname !== "/api/integrations/setup" && crossOriginMutation(request) ) return blocked(); // /api owns its own response headers (fs/raw sets `content-security-policy: // sandbox`, Cache-Control no-store) and serves no inline scripts — never stamp // the document CSP on it. if (isApi) { // An inbound copy of the app-host header would let a request on the COCKPIT // origin claim root-mounted mode at the old same-origin proxy URL — the exact // hole this split closes. Cloned only when there is something to strip, so the // ordinary path stays byte-for-byte as it was. if (request.headers.has(MANAGED_APP_HOST_HEADER)) { const stripped = new Headers(request.headers); stripped.delete(MANAGED_APP_HOST_HEADER); return NextResponse.next({ request: { headers: stripped } }); } return NextResponse.next(); } // Per-request nonce for the HTML document. Edge-safe (crypto + btoa, no // Buffer). Next reads the nonce from the REQUEST content-security-policy header // and auto-nonces its bootstrap + RSC scripts; x-nonce feeds our layout script. const nonce = btoa(crypto.randomUUID()); const csp = contentSecurityPolicy(nonce); const requestHeaders = new Headers(request.headers); requestHeaders.delete(MANAGED_APP_HOST_HEADER); requestHeaders.set("x-nonce", nonce); requestHeaders.set("content-security-policy", csp); const response = NextResponse.next({ request: { headers: requestHeaders } }); response.headers.set("Content-Security-Policy", csp); applyPrivatePageCachePolicy(response, request); return response; } // Everything: an app host has to be gated on the paths the old negative lookahead // excluded (/_next/static, /_next/image, favicon.ico) and a rewrite cannot fire for // a path middleware never sees — a Traefik-level block is not available. The // cockpit short-circuits those in MATCHER_EXCLUDED above. export const config = { // Routes that stream their own bounded body skip the clone middleware entirely. // Written out literally: a matcher is a compile-time string, so deriving it from // BODY_CLONE_BYPASS at runtime only looked equivalent while being impossible to // review (and the first escaped version silently failed to match). The policy test // asserts that this list and BODY_CLONE_BYPASS stay in sync. matcher: ["/((?!api/v1/fs/upload$|api/auth/account/icon$).*)"], };