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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user