feat(server,web): serve Workspace HTML previews from a separate origin (#46)

Serve "open in new tab" HTML previews from a separate origin (the loopback counterpart, or PENGUIN_PREVIEW_ORIGIN) with a signed, host-bound token, so localStorage/cookies/third-party embeds work while Agent-generated pages stay off the app origin. The app is canonicalized onto localhost and the preview host (127.0.0.1) serves only /preview/* — its /api answers 401 and app routes 302 to the canonical host — so the preview origin can neither set nor honor a session cookie.
This commit is contained in:
Yaowei Zheng
2026-07-23 23:11:04 +08:00
committed by GitHub
parent cc1be054de
commit b18ae8f5f1
22 changed files with 842 additions and 14 deletions
+14 -1
View File
@@ -10,12 +10,25 @@ import { toUserInfo } from "../../auth/service.js";
import type { AppEnv } from "../../auth/middleware.js";
import { readJson, requireString } from "../validate.js";
import type { AppDeps } from "../../app.js";
import { resolvePreviewTarget } from "../../services/preview-token.js";
export function meRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", (c) => {
return c.json({ user: toUserInfo(c.var.user) } satisfies MeResponse);
// previewIsolated depends on the host this request came in on, so it is computed
// here rather than stored: the same server answers on 127.0.0.1, localhost and
// possibly a LAN address, and only the first two have a loopback counterpart.
const target = resolvePreviewTarget(
c.req.url,
c.req.header("host"),
deps.config.previewOrigin,
deps.config,
);
return c.json({
user: toUserInfo(c.var.user),
previewIsolated: target !== null,
} satisfies MeResponse);
});
// Self-service password change (user settings): validates the old password; on success, the initial-password prompt disappears from GET /api/me.
@@ -0,0 +1,87 @@
/**
* Workspace HTML preview served from a separate origin.
*
* `GET /preview/:token/*` sits outside `/api` and outside the auth middleware: the
* preview origin never receives the session cookie, so the signed token in the path is
* the only credential. Path-based (rather than the query-parameter `files/content`
* endpoint) so a page's relative subresources — `app.js`, `style.css`, images — resolve
* against the document and actually load.
*
* Because the boundary is now the origin itself, the response does NOT carry the CSP
* sandbox: the page gets a normal origin with working storage and cookies, which is what
* lets third-party embeds run. That only stays safe while the host check below holds —
* this same process also answers on the App origin, and serving Agent-written HTML there
* would be a same-origin XSS with full API access.
*
* Design: design/specs/05-ARCHITECTURE.md § "Workspace 文件预览".
*/
import { Hono } from "hono";
import type { SessionsRepo } from "../../db/repos/sessions.js";
import type { WorkspaceFilesService } from "../../services/workspace-files-service.js";
import type { PreviewTokenSigner } from "../../services/preview-token.js";
import { hostOnly, requestAuthority } from "../../services/preview-token.js";
export interface PreviewDeps {
sessionsRepo: SessionsRepo;
workspaceFiles: WorkspaceFilesService;
previewTokens: PreviewTokenSigner;
}
/** Directory-style requests resolve to index.html, matching ordinary static hosting. */
const DIRECTORY_INDEX = "index.html";
export function previewRoutes(deps: PreviewDeps) {
const app = new Hono();
app.get("/:token/*", async (c) => {
const token = c.req.param("token");
const payload = token ? deps.previewTokens.verify(token) : null;
// A bad or expired token and a wrong host both answer 404 with no detail: this
// endpoint is unauthenticated, so it should not confirm what exists.
if (!payload) return c.text("Not found", 404);
// The host binding is the load-bearing check — see the file header.
const authority = requestAuthority(c.req.url, c.req.header("host"));
if (hostOnly(authority).toLowerCase() !== payload.host.toLowerCase()) {
return c.text("Not found", 404);
}
const row = deps.sessionsRepo.findById(payload.sessionId);
if (!row) return c.text("Not found", 404);
// Everything after `/preview/<token>/` is the Workspace-relative path; the service
// re-resolves it against the Workspace and rejects `..` and symlink escapes.
const prefix = `/preview/${token}/`;
const raw = c.req.path.startsWith(prefix) ? c.req.path.slice(prefix.length) : "";
let rel: string;
try {
rel = decodeURIComponent(raw);
} catch {
return c.text("Not found", 404);
}
if (rel === "" || rel.endsWith("/")) rel += DIRECTORY_INDEX;
let file;
try {
file = await deps.workspaceFiles.read(row.workspace, rel);
} catch {
return c.text("Not found", 404);
}
return new Response(new Uint8Array(file.data), {
status: 200,
headers: {
"Content-Type": file.contentType,
"X-Content-Type-Options": "nosniff",
// Without this, third-party requests made by the page leak the token-bearing
// URL through Referer — a risk that only exists because this origin exists to
// let third-party embeds run.
"Referrer-Policy": "no-referrer",
// Previews are per-token and short-lived; never let a shared cache keep them.
"Cache-Control": "no-store",
},
});
});
return app;
}
@@ -22,6 +22,7 @@ import type {
SessionsResponse,
TaskCreateResponse,
} from "../../api/types.js";
import { PREVIEW_TOKEN_TTL_MS, resolvePreviewTarget } from "../../services/preview-token.js";
import type { AppEnv } from "../../auth/middleware.js";
import type { SessionRow } from "../../db/repos/sessions.js";
import { assertWorkspaceAllowed } from "../../services/workspace-guard.js";
@@ -409,6 +410,55 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
});
});
// "Open in a new tab" for Workspace HTML: mints a token and redirects to the separate
// preview origin (see design § "Workspace 文件预览").
//
// A redirect rather than a JSON endpoint the UI fetches, because the alternative is
// worse on two counts: opening the tab after an await trips popup blockers, and a
// window opened by script keeps an `opener` handle back to the App — exactly the
// reference this design exists to deny. A plain link with rel="noopener noreferrer"
// has neither problem.
//
// Minting on GET is safe: a cross-site request can make the browser follow the
// redirect, but the response is opaque to the initiating page, so no token leaks — and
// what it would grant is a preview of the victim's own file.
//
// With no usable preview origin (the App is reached on something other than a loopback
// name and PENGUIN_PREVIEW_ORIGIN is unset), this falls back to the sandboxed
// same-origin preview: the page still renders, but storage and third-party embeds do
// not. The UI flags that ahead of time via `previewIsolated` on /api/me.
app.get("/:sessionId/files/preview-redirect", async (c) => {
const row = resolveSession(c);
const rel = c.req.query("path") ?? "";
// Validate existence + containment while the caller is still authenticated, so a bad
// path fails here rather than as an opaque 404 from the unauthenticated preview origin.
// A stat, not a read: the file itself is fetched later, on the preview origin — reading
// it here (up to 50MB) only to discard the bytes would be wasted work on every click.
const [exists] = await deps.workspaceFiles.statExisting(row.workspace, [rel]);
if (!exists) throw new HttpError(404, "file_not_found", "File does not exist.");
const target = resolvePreviewTarget(
c.req.url,
c.req.header("host"),
deps.config.previewOrigin,
deps.config,
);
if (!target) {
return c.redirect(
`/api/sessions/${row.sessionId}/files/content?path=${encodeURIComponent(rel)}&preview=1`,
302,
);
}
const token = deps.previewTokens.sign({
sessionId: row.sessionId,
host: target.host,
expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS,
});
const encoded = rel.split("/").map(encodeURIComponent).join("/");
return c.redirect(`${target.origin}/preview/${token}/${encoded}`, 302);
});
// Bulk existence check (message file cards list only files that actually exist):
// path-confinement resolution shares the same logic as files/content
// (WorkspaceFilesService.statExisting reuses resolveRead); out-of-bounds or