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