Ctrl K
ComunidadGuías…

proxy.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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$).*)"],
};