/** * Workspace HTML preview on the separate preview origin: token signing/verification, * the mint endpoint's origin derivation, and the preview route. * * The load-bearing case is "same token, App origin's Host": the preview route answers on * the same process as the App, so if it served Agent-written HTML there, it would be a * same-origin XSS with the session cookie attached. See design § "Workspace 文件预览". */ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createPreviewTokenSigner, hostOnly, loopbackCounterpart, loopbackHostRoles, resolvePreviewTarget, } from "../src/services/preview-token.js"; import type { ProjectCreateResponse, SessionCreateResponse } from "../src/api/types.js"; import { apiClient, createTestApp, provisionUser } from "./helpers.js"; import type { TestApp } from "./helpers.js"; describe("preview token signer", () => { const signer = createPreviewTokenSigner(); const payload = { sessionId: "s-1", host: "127.0.0.1", expiresAt: Date.now() + 60_000 }; it("round-trips a valid token", () => { expect(signer.verify(signer.sign(payload))).toEqual(payload); }); it("rejects a tampered payload, a foreign signature and malformed input", () => { const token = signer.sign(payload); const [body, sig] = token.split("."); // Re-encode a payload pointing at another Session, keeping the original signature. const forged = Buffer.from(JSON.stringify({ ...payload, sessionId: "s-2" }), "utf8").toString( "base64url", ); expect(signer.verify(`${forged}.${sig}`)).toBeNull(); // A different secret must not validate. expect(createPreviewTokenSigner().verify(token)).toBeNull(); for (const bad of ["", ".", "abc", `${body}.`, `.${sig}`, `${body}.zzzz`]) { expect(signer.verify(bad)).toBeNull(); } }); it("rejects an expired token", () => { expect(signer.verify(signer.sign({ ...payload, expiresAt: Date.now() - 1 }))).toBeNull(); }); }); describe("preview origin derivation", () => { it("strips the port, keeping IPv6 brackets", () => { expect(hostOnly("127.0.0.1:7364")).toBe("127.0.0.1"); expect(hostOnly("localhost")).toBe("localhost"); expect(hostOnly("[::1]:7364")).toBe("[::1]"); }); it("maps loopback names to their counterpart and nothing else", () => { expect(loopbackCounterpart("127.0.0.1")).toBe("localhost"); expect(loopbackCounterpart("localhost")).toBe("127.0.0.1"); expect(loopbackCounterpart("[::1]")).toBe("127.0.0.1"); // A LAN IP or a real domain has no safe counterpart — those need explicit config. expect(loopbackCounterpart("192.168.1.5")).toBeNull(); expect(loopbackCounterpart("penguin.example.com")).toBeNull(); }); it("assigns fixed App/preview roles only for loopback binds", () => { // localhost is the canonical App host; 127.0.0.1 is reserved for previews. Fixed, not // "counterpart of whoever asked", so the preview host is never a host the App logs in on. expect(loopbackHostRoles("127.0.0.1")).toEqual({ app: "localhost", preview: "127.0.0.1" }); expect(loopbackHostRoles("localhost")).toEqual({ app: "localhost", preview: "127.0.0.1" }); // Wildcard / LAN / bare ::1 binds get no loopback roles — they must configure an origin. expect(loopbackHostRoles("0.0.0.0")).toBeNull(); expect(loopbackHostRoles("::")).toBeNull(); expect(loopbackHostRoles("192.168.1.5")).toBeNull(); }); const bind = { host: "127.0.0.1", port: 7364 }; it("derives the counterpart origin on the server's own port", () => { expect(resolvePreviewTarget("http://127.0.0.1:7364/x", "127.0.0.1:7364", null, bind)).toEqual({ origin: "http://localhost:7364", host: "localhost", }); expect( resolvePreviewTarget("http://localhost:80/x", "localhost", null, { ...bind, port: 80 }), ).toEqual({ origin: "http://127.0.0.1", host: "127.0.0.1" }); }); it("uses the server port, not the browser's — dev serves the SPA on a different port", () => { // `pnpm dev`: the SPA is on Vite:7365 and only /api is proxied, so a preview URL on // :7365 would hit a port that does not serve /preview at all. expect(resolvePreviewTarget("http://localhost:7365/x", "localhost:7365", null, bind)).toEqual({ origin: "http://127.0.0.1:7364", host: "127.0.0.1", }); }); it("gives up when the loopback counterpart is not reachable from the bind address", () => { expect( resolvePreviewTarget("http://localhost:7364/x", "localhost:7364", null, { host: "192.168.1.5", port: 7364, }), ).toBeNull(); // A wildcard bind (0.0.0.0 / ::) no longer qualifies: localhost may resolve to ::1, // which 0.0.0.0 never serves, so the preview URL would refuse the connection while // /api/me claimed isolation. Only a loopback-name bind offers a loopback preview; the // rest fall back and must set PENGUIN_PREVIEW_ORIGIN. expect( resolvePreviewTarget("http://localhost:7364/x", "localhost:7364", null, { host: "0.0.0.0", port: 7364, }), ).toBeNull(); }); it("prefers a configured origin, and gives up on hosts with no counterpart", () => { expect( resolvePreviewTarget( "https://app.example.com/x", "app.example.com", "https://p.example.com", bind, ), ).toEqual({ origin: "https://p.example.com", host: "p.example.com" }); expect( resolvePreviewTarget("http://192.168.1.5:7364/x", "192.168.1.5:7364", null, bind), ).toBeNull(); }); it("refuses a configured origin that matches the App request's host", () => { // PENGUIN_PREVIEW_ORIGIN pointing back at the host the App is being used on is no // boundary: "isolated" previews would be same-origin with the App (and the UI would // mount an allow-same-origin iframe onto them). Must degrade to null so // previewIsolated reports false and the safe same-origin sandbox engages. expect( resolvePreviewTarget( "https://app.example.com/x", "app.example.com", "https://app.example.com", bind, ), ).toBeNull(); // Port and case differences don't rescue it: cookies ignore ports, hosts ignore case. expect( resolvePreviewTarget( "https://app.example.com/x", "app.example.com:8443", "https://APP.example.com:9443", bind, ), ).toBeNull(); // A genuinely different host on the same registrable domain is still accepted. expect( resolvePreviewTarget( "https://app.example.com/x", "app.example.com", "https://preview.app.example.com", bind, ), ).toEqual({ origin: "https://preview.app.example.com", host: "preview.app.example.com" }); }); }); describe("preview route", () => { let t: TestApp; let owner: ReturnType; let ownerCookie: string; let sessionId: string; let workspace: string; beforeEach(async () => { t = await createTestApp(); const a = await provisionUser(t.app, "owner"); owner = apiClient(t.app, a.cookie); ownerCookie = a.cookie; const created = (await ( await owner.post("/api/projects", { projectId: "owner-preview", name: "project" }) ).json()) as ProjectCreateResponse; const projectId = created.project.projectId; await owner.put(`/api/projects/${projectId}/models`, { defaultModel: { provider: "anthropic", modelId: "claude-sonnet-4-6" }, models: [{ provider: "anthropic", modelId: "claude-sonnet-4-6", contextWindow: 128000 }], }); const sess = (await ( await owner.post(`/api/projects/${projectId}/agents/default_agent/sessions`, {}) ).json()) as SessionCreateResponse; sessionId = sess.session.sessionId; workspace = sess.session.workspace; await fs.mkdir(path.join(workspace, "assets")); await fs.writeFile(path.join(workspace, "index.html"), "