proxy.ts
Rama main · réplica de lectura del repositorio original.
// 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$).*)"],
};