Ctrl K
ComunidadGuías…

proxy.test.ts

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

Archivo original ↗Preguntar a KiBot
// Middleware host gating. Two modes, and BOTH are load-bearing:
//  • split origin (NEXT_PUBLIC_MANAGED_APP_HOST_TEMPLATE set) — on an app host
//    every path becomes that app's proxy and nothing else is reachable. That
//    gating is what makes the widened session cookie safe on those hosts, so the
//    "cockpit is unreachable" cases below are security tests, not routing tests.
//  • single origin (unset) — dev, demo and the rollback path, unchanged.
// A middleware rewrite is asserted through the response contract Next uses for it:
// `x-middleware-rewrite` for the destination and `x-middleware-request-*` for the
// request headers handed to the route.
import { afterEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/managed-apps/session", () => ({ gateManagedApp: vi.fn(async () => null) }));

const TEMPLATE = "{id}.mso.example.com";
const HERMES = "/api/v1/managed-apps/hermes/proxy";
const APP_HOST_HEADER = "x-os-managed-app-host";

/** Env is read at module load, so each mode needs a fresh module graph. */
async function loadProxy(template: string) {
  vi.resetModules();
  vi.stubEnv("NEXT_PUBLIC_MANAGED_APP_HOST_TEMPLATE", template);
  return (await import("./proxy")).proxy;
}

/** As it arrives from Traefik: passHostHeader:true, so Host is the public name. */
function req(
  host: string,
  path: string,
  init: { method?: string; headers?: HeadersInit; body?: string } = {},
): NextRequest {
  const headers = new Headers(init.headers);
  headers.set("host", host);
  return new NextRequest(`https://${host}${path}`, { ...init, headers });
}

const rewriteOf = (res: Response) => res.headers.get("x-middleware-rewrite");
const stampedHost = (res: Response) =>
  res.headers.get(`x-middleware-request-${APP_HOST_HEADER}`);

afterEach(() => {
  vi.unstubAllEnvs();
});

describe("app host → that app's proxy, and nothing else", () => {
  it.each([
    ["/", HERMES],
    ["/login", `${HERMES}/login`],
    ["/assets/index-CEmUNp2y.js", `${HERMES}/assets/index-CEmUNp2y.js`],
    // Next 308s a trailing slash away; that redirect would come back to this host
    // and be rewritten a SECOND time, one prefix deeper.
    ["/sessions/", `${HERMES}/sessions`],
  ])("rewrites %s into the proxy", async (path, expected) => {
    const proxy = await loadProxy(TEMPLATE);
    const res = await proxy(req("hermes.mso.example.com", path));
    expect(new URL(rewriteOf(res)!).pathname).toBe(expected);
  });

  it("preserves the query string", async () => {
    const proxy = await loadProxy(TEMPLATE);
    const res = await proxy(
      req("hermes.mso.example.com", "/login?next=%2Fchat"),
    );
    expect(rewriteOf(res)).toBe(
      `https://hermes.mso.example.com${HERMES}/login?next=%2Fchat`,
    );
  });

  it("routes each host to its OWN app — one origin per app, never a shared one", async () => {
    const proxy = await loadProxy(TEMPLATE);
    const res = await proxy(req("openclaw.mso.example.com", "/chat"));
    expect(new URL(rewriteOf(res)!).pathname).toBe(
      "/api/v1/managed-apps/openclaw/proxy/chat",
    );
    expect(stampedHost(res)).toBe("openclaw");
  });

  // The cockpit surface must not merely be un-linked here, it must not ANSWER: the
  // session cookie is sent to this host, so /api/v1/exec/run would run commands.
  it.each([
    ["the exec API", "/api/v1/exec/run"],
    ["the auth API", "/api/auth/login"],
    ["the device API", "/api/auth/devices"],
    ["a cockpit page", "/settings"],
    ["the service worker", "/sw.js"],
  ])(
    "sends %s into the app proxy instead of the cockpit",
    async (_label, path) => {
      const proxy = await loadProxy(TEMPLATE);
      const res = await proxy(
        req("hermes.mso.example.com", path, {
          headers: { "sec-fetch-site": "same-origin" },
        }),
      );
      const rewritten = new URL(rewriteOf(res)!).pathname;
      expect(rewritten).toBe(`${HERMES}${path}`);
      // Whatever the upstream answers, it is not the cockpit route of that name.
      expect(rewritten.startsWith(`${HERMES}/`)).toBe(true);
    },
  );

  // The matcher cannot exclude by host, and a rewrite cannot fire for a path
  // middleware never sees — so the exclusions were dropped and these are blocked
  // here. No dashboard serves /_next/*; it is only ever the cockpit's namespace.
  it.each([
    "/_next/static/chunks/main.js",
    "/_next/image?url=%2Fx.png",
    "/_next",
  ])("404s %s on an app host", async (path) => {
    const proxy = await loadProxy(TEMPLATE);
    const res = await proxy(req("hermes.mso.example.com", path));
    expect(res.status).toBe(404);
    expect(rewriteOf(res)).toBeNull();
  });

  // Hermes' index.html links `/favicon.ico`, so this one belongs to the app.
  it("still proxies /favicon.ico, which the dashboard serves itself", async () => {
    const proxy = await loadProxy(TEMPLATE);
    expect(
      new URL(
        rewriteOf(await proxy(req("hermes.mso.example.com", "/favicon.ico")))!,
      ).pathname,
    ).toBe(`${HERMES}/favicon.ico`);
  });

  it("never stamps the cockpit document policy on an app host", async () => {
    const proxy = await loadProxy(TEMPLATE);
    const res = await proxy(req("hermes.mso.example.com", "/"));
    // The route sets its own, scoped to the app origin — see proxy-csp.ts.
    expect(res.headers.get("content-security-policy")).toBeNull();
    expect(res.headers.get("x-middleware-request-x-nonce")).toBeNull();
  });

  it("overwrites a client-supplied copy of the app-host header", async () => {
    const proxy = await loadProxy(TEMPLATE);
    // Forged, it would buy root-mounted mode: upstream cookies at Path=/ and a
    // policy naming the cockpit as a legal framer.
    const res = await proxy(
      req("hermes.mso.example.com", "/", {
        headers: { [APP_HOST_HEADER]: "openclaw" },
      }),
    );
    expect(stampedHost(res)).toBe("hermes");
  });

  it("leaves a host that is not an app host on the cockpit", async () => {
    const proxy = await loadProxy(TEMPLATE);
    const res = await proxy(req("mso.example.com", "/apps"));
    expect(rewriteOf(res)).toBeNull();
    expect(res.headers.get("content-security-policy")).toContain(
      "default-src 'self'",
    );
  });

  it("ignores x-forwarded-host, which a client can set, in favour of Host", async () => {
    const proxy = await loadProxy(TEMPLATE);
    // Escaping the rewrite is the dangerous direction: this request is ON the app
    // host and claims the cockpit, which would reach /api/v1/exec with a session
    // cookie that IS sent here.
    const res = await proxy(
      req("hermes.mso.example.com", "/api/v1/exec/run", {
        method: "POST",
        headers: {
          "x-forwarded-host": "mso.example.com",
          "sec-fetch-site": "same-origin",
        },
      }),
    );
    expect(new URL(rewriteOf(res)!).pathname).toBe(`${HERMES}/api/v1/exec/run`);
  });
});

describe("CSRF depth-2 on an app host", () => {
  // Everything here becomes /api/… only AFTER the rewrite, so the gate has to run
  // before it or it never runs at all.
  it("passes the frame's own same-origin form POST", async () => {
    const proxy = await loadProxy(TEMPLATE);
    const res = await proxy(
      req("hermes.mso.example.com", "/auth/password-login", {
        method: "POST",
        headers: { "sec-fetch-site": "same-origin" },
      }),
    );
    expect(res.status).toBe(200);
    expect(rewriteOf(res)).not.toBeNull();
  });

  it.each([
    ["cross-site", "cross-site"],
    // The cockpit and an app host share a registrable domain, so cockpit JS
    // reaching in is same-SITE — and that reach is what the split removes.
    ["the cockpit origin (same-site)", "same-site"],
  ])("blocks a mutating request from %s", async (_label, site) => {
    const proxy = await loadProxy(TEMPLATE);
    const res = await proxy(
      req("hermes.mso.example.com", "/api/tasks", {
        method: "POST",
        headers: { "sec-fetch-site": site },
      }),
    );
    expect(res.status).toBe(403);
    expect(rewriteOf(res)).toBeNull();
  });

  it("falls back to an Origin host match for non-browser clients", async () => {
    const proxy = await loadProxy(TEMPLATE);
    const ok = await proxy(
      req("hermes.mso.example.com", "/api/tasks", {
        method: "POST",
        headers: { origin: "https://hermes.mso.example.com" },
      }),
    );
    expect(rewriteOf(ok)).not.toBeNull();
    const bad = await proxy(
      req("hermes.mso.example.com", "/api/tasks", {
        method: "POST",
        headers: { origin: "https://evil.example" },
      }),
    );
    expect(bad.status).toBe(403);
  });

  it("still routes a GET, which mutates nothing", async () => {
    const proxy = await loadProxy(TEMPLATE);
    const res = await proxy(
      req("hermes.mso.example.com", "/chat", {
        headers: { "sec-fetch-site": "cross-site" },
      }),
    );
    expect(new URL(rewriteOf(res)!).pathname).toBe(`${HERMES}/chat`);
  });
});

describe("the app-host header is never accepted from a client", () => {
  it.each(["", TEMPLATE])(
    "strips an inbound copy on the cockpit origin (template %s)",
    async (template) => {
      const proxy = await loadProxy(template);
      // Reaching the proxy route with this header set would restore root-mounted mode
      // at the old same-origin URL — cookies at Path=/ and the cockpit named as a
      // permitted framer, i.e. exactly the hole the split closes.
      const res = await proxy(
        req("mso.example.com", `${HERMES}/chat`, {
          headers: { [APP_HOST_HEADER]: "hermes" },
        }),
      );
      expect(stampedHost(res)).toBeNull();
      expect(
        res.headers.get("x-middleware-override-headers") ?? "",
      ).not.toContain(APP_HOST_HEADER);
    },
  );

  it("strips it from a cockpit document request too", async () => {
    const proxy = await loadProxy(TEMPLATE);
    const res = await proxy(
      req("mso.example.com", "/apps", {
        headers: { [APP_HOST_HEADER]: "hermes" },
      }),
    );
    expect(stampedHost(res)).toBeNull();
  });
});

describe("cockpit document cache boundary", () => {
  it("applies the private page policy through proxy.ts", async () => {
    const proxy = await loadProxy(TEMPLATE);
    const res = await proxy(req("mso.example.com", "/settings", { headers: { accept: "text/html" } }));
    expect(res.headers.get("cache-control")).toBe("private, no-store, max-age=0, must-revalidate");
    expect((res.headers.get("vary") ?? "").toLowerCase()).toContain("cookie");
  });
});