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
@@ -0,0 +1,11 @@
# Web App: Workspace HTML previews open on a separate origin
"Open in new tab" for a Workspace html file used to serve the page from the app's own origin under a CSP sandbox without `allow-same-origin`. That kept Agent-generated markup away from the session cookie and the API, but it also put the document in an opaque origin, so `localStorage` and `document.cookie` threw `SecurityError` and any third-party embed on the page — analytics, chat widgets, maps — failed outright. The query-parameter URL had a second problem: a page's relative subresources resolved to `/api/sessions/<id>/files/<name>`, a route that does not exist, so multi-file generated apps could never load their own CSS or JS.
Previews now open on a **separate origin** with path-based URLs. The link points at `GET /files/preview-redirect?path=`, which authenticates the caller, mints a short-lived HMAC token bound to the Session, the preview host and an expiry, then 302s to `/preview/<token>/<path>`. Locally the app is canonicalized onto one loopback name (`localhost`) and previews are served from the other (`127.0.0.1`) — cookies are keyed by host and ignore port, so those are genuinely separate cookie jars, which a second port would not have given. Real deployments set `PENGUIN_PREVIEW_ORIGIN`; with neither, the redirect falls back to the old same-origin sandbox and `previewIsolated` on `GET /api/me` reports `false`, so the Files panel marks the link rather than letting the page break silently.
Because the origin is now the isolation boundary, the preview response no longer strips `allow-same-origin`: the page gets a normal origin, storage and cookies work, and embeds run. Several details keep that safe. The app and the preview origin are the same process on two hostnames, so the preview host is locked to serving `/preview/*` only: its `/api` answers `401` and every app route `302`s to the canonical host, so a session cookie is never set or honored there — otherwise Agent HTML on the preview origin could call `/api` same-origin and act as the user. The token binds the preview host and `/preview/...` refuses to serve on the app origin, without which this would be a same-origin XSS with full API access. Responses carry `Referrer-Policy: no-referrer`, since the token-bearing URL would otherwise leak through `Referer` to exactly the third parties this change enables. And the path is still re-resolved against the Workspace server-side, so `..` and symlink escapes are rejected as before; a bad token, an expired one, the wrong host and an out-of-bounds path all answer a bare 404.
The preview URL takes its port from the server's own binding rather than the browser's current origin. Those differ in development — the SPA runs on Vite's port and only `/api` is proxied — so deriving it from the request would point at a port that does not serve `/preview` at all, and the tab would fail to connect. In production the two coincide. Only a loopback-name bind (`127.0.0.1` / `localhost`) offers a loopback preview; a wildcard bind (`0.0.0.0` / `::`) or a specific non-loopback address has no reachable counterpart — `localhost` may resolve to `::1`, which a `0.0.0.0` bind never serves — so those fall back and must set `PENGUIN_PREVIEW_ORIGIN`, rather than pointing somewhere dead while `/api/me` claims isolation.
The link is a plain anchor with `rel="noopener noreferrer"` rather than a fetch followed by `window.open`: opening a tab after an await trips popup blockers, and a script-opened window keeps an `opener` handle back to the app — the exact reference the separate origin exists to deny. The server also binds the IPv6 loopback alongside IPv4, because `localhost` commonly resolves to `::1` first and every preview URL would otherwise refuse the connection.
+2
View File
@@ -1,3 +1,5 @@
# Unreleased
Changes since v0.1.1. The version number is assigned at release, when this folder is renamed.
- [2026-07-22] Web App: Workspace HTML previews open on a separate origin with a signed token, so `localStorage`, cookies and third-party embeds work while Agent-generated pages still cannot reach the session cookie or the API. ([details](2026-07-22-web-app.md))
+4 -1
View File
@@ -81,7 +81,10 @@ async function waitForReady(url: string, timeoutMs = 15_000, intervalMs = 300):
// Each probe is capped at 1s: if the port is held by a non-HTTP program, the
// connection can succeed while the response hangs forever; without a timeout this
// would block the whole polling loop (the deadline check below would never run).
const res = await fetch(url, { signal: AbortSignal.timeout(1000) });
// `redirect: "manual"`: on a loopback bind the root path 302s to the canonical host
// (127.0.0.1 is reserved for previews); the probe only needs to know the port answers,
// so treat that 302 as ready rather than chasing it to a name that may resolve to ::1.
const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(1000) });
void res.body?.cancel();
return true;
} catch {
@@ -16,8 +16,11 @@ The CLI and the server automatically load a `.env` file from the working directo
| `HOST` | Web service listen address | `127.0.0.1` |
| `PENGUIN_WEB_DB` | Server SQLite database path | `<root>/web.db` |
| `PENGUIN_WEB_DIST` | Front-end static assets directory | the npm server package falls back to its bundled web-dist |
| `PENGUIN_PREVIEW_ORIGIN` | Origin that serves Workspace HTML previews, e.g. `https://preview.example.com` | unset — the loopback counterpart is derived per request |
| `PENGUIN_LANG` | CLI language (`en` / `zh`), set via `penguin config lang` | `en` |
`PENGUIN_PREVIEW_ORIGIN` must differ from the app's origin by **hostname**, not just port: cookies ignore ports, so a second port would still share the session cookie. Leave it unset for local use — the app is canonicalized onto `localhost` and previews are served from `127.0.0.1`, which needs no configuration and no DNS. Set it when the app is reached over a LAN address or a real domain; otherwise previews there fall back to a same-origin sandbox where `localStorage`, cookies and third-party embeds do not work. When you do set it on a real domain, keep the session cookie host-only (no `Domain=`), or a sibling subdomain shares it. An unparseable value is a startup error rather than a silent fallback.
### Provider credential variables
When a model entry has no inline `api_key`, the AgentHub gateway falls back to the provider's environment variable; the `*_BASE_URL` variants override the base URL the same way:
@@ -16,8 +16,11 @@ CLI 与服务端启动时会自动加载工作目录下的 `.env` 文件。
| `HOST` | Web 服务监听地址 | `127.0.0.1` |
| `PENGUIN_WEB_DB` | 服务端 SQLite 数据库路径 | `<root>/web.db` |
| `PENGUIN_WEB_DIST` | 前端静态资源目录 | npm 安装的服务端包回退到内置 web-dist |
| `PENGUIN_PREVIEW_ORIGIN` | 提供 Workspace HTML 预览的独立源,如 `https://preview.example.com` | 未设置,按请求推导回环对应名 |
| `PENGUIN_LANG` | CLI 语言(`en` / `zh`),用 `penguin config lang` 设置 | `en` |
`PENGUIN_PREVIEW_ORIGIN` 必须与应用源在**主机名**上不同,只换端口不行:Cookie 不区分端口,换端口仍然共用会话 Cookie。本地使用不必配置——App 固定在规范主机 `localhost`,预览用 `127.0.0.1`,既不需要配置也不需要 DNS。经 LAN 地址或真实域名访问时才需要设置,否则那里的预览会回退到同源沙箱,`localStorage`、Cookie 与第三方 embed 都不可用。在真实域名上设置时,会话 Cookie 必须保持 host-only(不带 `Domain=`),否则同注册域下的兄弟子域会共享它。取值无法解析时启动即报错,不会静默回退。
### Provider 凭证环境变量
当模型条目未内联 `api_key` 时,AgentHub 网关按 Provider 回退读取对应环境变量;`*_BASE_URL` 变体同理覆盖 Base URL:
+19 -1
View File
@@ -150,6 +150,7 @@ The paths below omit the `/api/sessions/:sessionId` prefix. For the storage mode
| POST | /compact | Trigger context compaction: 202; 409 `nothing_to_compact` when there is nothing to compact |
| GET | /files?path= | Browse the Workspace directory |
| GET | /files/content?path=&download=&preview= | Read a Workspace file (`download=1` serves it as an attachment, `preview=1` renders it in a sandbox — see below) |
| GET | /files/preview-redirect?path= | "Open in a new tab" for html: mints a signed token and 302s to the separate preview origin |
| POST | /files/stat | Batch existence check: `{paths}` |
| PUT | /files/content?path= | Upload a file: `{dataBase64}`, capped at 14MB |
| GET | /traces | List this Session's Trace files |
@@ -167,7 +168,24 @@ Workspace files may be Agent-generated, so `GET /files/content` treats them as u
| `preview=1` | the real type (`text/html`, `image/svg+xml`, …) | `inline` | `sandbox allow-scripts allow-popups allow-modals allow-forms`, sent only for `.html` / `.htm` / `.svg` |
| `download=1` | the real type | `attachment` | — |
The filename always rides along as `filename*=UTF-8''` with percent-encoding. `preview=1` is what backs "open in a new tab": the document keeps its real type and does render and run, but the sandbox deliberately omits `allow-same-origin`, so it lands in an opaque origin and can reach neither this origin's cookies nor the API — while the request itself still authenticates, because a top-level GET sends the SameSite=Lax session cookie.
The filename always rides along as `filename*=UTF-8''` with percent-encoding. `preview=1` renders inside the Files panel's sandboxed iframe, and is also the fallback for "open in a new tab" when no separate preview origin is available: the document keeps its real type and does render and run, but the sandbox deliberately omits `allow-same-origin`, so it lands in an opaque origin and can reach neither this origin's cookies nor the API. That isolation is also why `localStorage`, `document.cookie` and third-party embeds do not work there.
### Preview on a separate origin
"Open in a new tab" goes through `GET /files/preview-redirect?path=`, which authenticates the caller, then mints a short-lived HMAC token and 302s to a **different origin**:
```text
GET /api/sessions/:sessionId/files/preview-redirect?path=index.html
302 Location: http://localhost:7364/preview/<token>/index.html
GET /preview/<token>/<relative path> (unauthenticated; the token is the credential)
```
- **Why a separate origin.** The page needs a real origin to have working storage, cookies and third-party embeds — but it must not be the app's origin, or Agent-written HTML would run with the session cookie. Locally the app is canonicalized onto `localhost` and previews are served from `127.0.0.1`; cookies are keyed by host and ignore port, so those are separate cookie jars while a second port would not be. Otherwise `PENGUIN_PREVIEW_ORIGIN` applies; with neither (a wildcard or non-loopback bind, or the variable unset), the redirect falls back to the same-origin sandbox above and `previewIsolated` on `GET /api/me` reports `false` so the UI can say so first.
- **The preview host serves only `/preview/*`.** It is the same process as the app, so it answers `/api` with `401` and `302`s every other route to the canonical app host. A session cookie is therefore never set or honored on the preview host, and Agent HTML there cannot reach the API same-origin. (For a deployed `PENGUIN_PREVIEW_ORIGIN`, the reverse proxy must enforce the equivalent: route only `/preview/*` to the app on that origin.)
- **Path-based, not a query parameter**, so a page's relative subresources (`app.js`, `style.css`, images) resolve against the document and load under the same token.
- **The token binds the Session, the preview host and an expiry.** The host binding is load-bearing: the same process also answers on the app origin, so `/preview/...` refuses to serve there — otherwise it would be a same-origin XSS. Access is read-only and scoped to that Session's Workspace, and the path is re-resolved server-side, so `..` and symlink escapes are rejected as before.
- **Responses carry `Referrer-Policy: no-referrer`**, or the token-bearing URL would leak through `Referer` to every third party the page embeds — a risk that exists precisely because embeds now work.
- Bad token, expired token, wrong host and out-of-bounds path all answer a bare 404: the endpoint is unauthenticated and must not confirm what exists.
Key request bodies (explicit keys):
+19 -1
View File
@@ -150,6 +150,7 @@ Schedule 写操作仅限 Owner。新建 Session 模式的任务,`modelId` 与
| POST | /compact | 触发上下文压缩:202;无可压缩内容返回 409 `nothing_to_compact` |
| GET | /files?path= | 浏览 Workspace 目录 |
| GET | /files/content?path=&download=&preview= | 读取 Workspace 文件(`download=1` 时作为附件下载,`preview=1` 以沙箱方式预览 —— 见下) |
| GET | /files/preview-redirect?path= | html 的“新页面打开”:签发令牌并 302 跳转到独立预览源 |
| POST | /files/stat | 批量存在性检查:`{paths}` |
| PUT | /files/content?path= | 上传文件:`{dataBase64}`,上限 14MB |
| GET | /traces | 本 Session 的 Trace 文件列表 |
@@ -167,7 +168,24 @@ Workspace 文件可能由 Agent 生成,`GET /files/content` 一律按不可信
| `preview=1` | 真实类型(`text/html`、`image/svg+xml` 等) | `inline` | `sandbox allow-scripts allow-popups allow-modals allow-forms`,仅对 `.html` / `.htm` / `.svg` 下发 |
| `download=1` | 真实类型 | `attachment` | 无 |
文件名始终以 `filename*=UTF-8''` 形式携带(百分号编码)。`preview=1` 支撑的是“在新标签页打开”:文档保留真实类型,可以正常渲染并执行脚本,但沙箱刻意不含 `allow-same-origin`,因此它落在一个不透明源里,既拿不到本源的 Cookie,也调不动 API;而请求本身仍然要鉴权 —— 顶层 GET 会带上 SameSite=Lax 的会话 Cookie。
文件名始终以 `filename*=UTF-8''` 形式携带(百分号编码)。`preview=1` 用于 Files 面板内的沙箱 iframe 渲染,同时也是“新页面打开”在没有独立预览源时的回退:文档保留真实类型,可以正常渲染并执行脚本,但沙箱刻意不含 `allow-same-origin`,因此它落在一个不透明源里,既拿不到本源的 Cookie,也调不动 API。这份隔离也正是那里 `localStorage`、`document.cookie` 与第三方 embed 全都不可用的原因。
### 独立源预览
“新页面打开”走 `GET /files/preview-redirect?path=`:先鉴权,再签发一枚短时效 HMAC 令牌,然后 302 跳转到**另一个源**:
```text
GET /api/sessions/:sessionId/files/preview-redirect?path=index.html
302 Location: http://localhost:7364/preview/<token>/index.html
GET /preview/<token>/<相对路径> (不鉴权,令牌即凭证)
```
- **为什么要独立源。** 页面需要一个真实的源,才能有可用的 storage、Cookie 与第三方 embed;但它不能是应用自己的源,否则 Agent 写出来的 HTML 就带着会话 Cookie 在跑。本地把 App 固定在规范主机 `localhost`,预览用 `127.0.0.1`——Cookie 按主机划分且不区分端口,所以这两者天然是两个 Cookie jar,而只换端口做不到。其余情况用 `PENGUIN_PREVIEW_ORIGIN`;两者都没有时(通配或非回环绑定,或变量未设)回退到上面的同源沙箱,并由 `GET /api/me` 的 `previewIsolated` 返回 `false`,界面据此提前说明。
- **预览主机只服务 `/preview/*`。** 它与 App 是同一个进程,故其 `/api` 一律 401,其余路径一律 302 回规范 App 主机——会话 Cookie 因此永远不会落在预览主机上,也不被其接受,那里的 Agent HTML 无法同源调用 API。(部署 `PENGUIN_PREVIEW_ORIGIN` 时,反向代理须做等价保证:该源上只把 `/preview/*` 路由到 App。)
- **路径式而非查询参数**,页面里的相对子资源(`app.js`、`style.css`、图片)才能相对文档解析,并在同一个令牌下加载。
- **令牌绑定 Session、预览主机与过期时间。** 其中主机绑定是承重的:同一个进程也在应用源上应答,因此 `/preview/...` 在应用源上一律拒绝服务——否则那就是一个同源 XSS。权限只读、限定该 Session 的 Workspace,路径仍在服务端重新解析,`..` 与符号链接逃逸照旧拒绝。
- **响应带 `Referrer-Policy: no-referrer`**,否则带令牌的 URL 会经 `Referer` 泄漏给页面内嵌的每一个第三方——而这个风险恰恰是因为 embed 现在能用了才出现的。
- 令牌无效、过期、主机不符与路径越界一律返回裸 404:该端点不鉴权,不能确认任何东西是否存在。
关键请求体(明确键名):
+9
View File
@@ -63,6 +63,15 @@ export interface AuthResponse {
export interface MeResponse {
user: UserInfo;
/**
* Whether Workspace HTML previews open on a separate origin (see design §
* "Workspace 文件预览"). False means this deployment has no usable preview origin —
* the App is reached on something other than a loopback name and
* PENGUIN_PREVIEW_ORIGIN is unset — so previews fall back to the same-origin sandbox,
* where `localStorage`, cookies and third-party embeds do not work. Computed per
* request, since it depends on the host the caller is using.
*/
previewIsolated: boolean;
}
export interface PasswordChangeRequest {
+46
View File
@@ -67,6 +67,14 @@ import { SessionService } from "./services/session-service.js";
import { TraceService } from "./services/trace-service.js";
import { UsageService } from "./services/usage-service.js";
import { WorkspaceFilesService } from "./services/workspace-files-service.js";
import {
createPreviewTokenSigner,
hostOnly,
loopbackHostRoles,
requestAuthority,
} from "./services/preview-token.js";
import type { PreviewTokenSigner } from "./services/preview-token.js";
import { previewRoutes } from "./http/routes/preview.js";
/** Request body size limit (tasks may carry data: images): 20MB. */
const MAX_BODY_BYTES = 20 * 1024 * 1024;
@@ -86,6 +94,8 @@ export interface AppDeps {
traceService: TraceService;
usageService: UsageService;
workspaceFiles: WorkspaceFilesService;
/** Signs/verifies short-lived Workspace preview tokens (separate preview origin). */
previewTokens: PreviewTokenSigner;
benchmarks: BenchmarkService;
snapshots: SnapshotService;
schedulesRepo: SchedulesRepo;
@@ -130,6 +140,9 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
const agentService = new AgentService(config.root, agentsRepo, agentConfigService);
const traceService = new TraceService(config.root);
const workspaceFiles = new WorkspaceFilesService();
// Per-process secret: preview tokens are short-lived, so losing them on restart is
// harmless and there is nothing to persist or rotate.
const previewTokens = createPreviewTokenSigner();
const benchmarks = new BenchmarkService(config.root);
const snapshots = new SnapshotService(config.root);
const usageService = new UsageService(
@@ -235,6 +248,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
traceService,
usageService,
workspaceFiles,
previewTokens,
benchmarks,
snapshots,
schedulesRepo,
@@ -274,6 +288,32 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
deps.log(`${c.req.method} ${c.req.path} ${c.res.status} ${ms}ms`);
});
// Canonical-host guard (loopback binds only): the App is served on one loopback name and
// previews on its counterpart, but the SAME process answers on both. Without this, Agent-
// written preview HTML on the preview host could call /api same-origin and — if a session
// cookie had ever been set on that host — act as the user. So the preview host serves ONLY
// /preview/*: /api answers 401 (it never sets or honors a cookie there, closing both the
// login and the stale-cookie paths), and everything else 302s to the canonical App host.
// See design § "Workspace 文件预览". Off when PENGUIN_PREVIEW_ORIGIN is set: previews then
// use that origin rather than the loopback counterpart, so 127.0.0.1 is an ordinary App
// access point and must not be locked down — deployments enforce the equivalent at the
// reverse proxy (route only /preview/* to the App on the preview origin).
const previewRoles = deps.config.previewOrigin ? null : loopbackHostRoles(deps.config.host);
if (previewRoles) {
app.use("*", async (c, next) => {
const host = hostOnly(requestAuthority(c.req.url, c.req.header("host"))).toLowerCase();
if (host === previewRoles.preview && !c.req.path.startsWith("/preview/")) {
if (c.req.path.startsWith("/api/")) {
throw new HttpError(401, "unauthorized", "The API is not served on the preview host.");
}
const url = new URL(c.req.url);
url.hostname = previewRoles.app;
return c.redirect(url.toString(), 302);
}
await next();
});
}
// API common defenses: request body size cap (20MB) and write-request Content-Type (one of the CSRF MVP defenses).
app.use("/api/*", async (c, next) => {
const contentLength = Number(c.req.header("content-length") ?? 0);
@@ -311,6 +351,12 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
app.route("/api/projects/:projectId/usage", usageRoutes(deps));
app.route("/api/sessions", sessionsRoutes(deps));
// Workspace HTML preview on the separate preview origin: deliberately outside /api and
// outside the auth middleware — that origin never receives the session cookie, so the
// signed token in the path is the only credential. Mounted before static hosting so the
// SPA fallback cannot swallow it. See design § "Workspace 文件预览".
app.route("/preview", previewRoutes(deps));
// Static hosting (production): serves the frontend build output when webDist exists, with SPA fallback to index.html.
if (fs.existsSync(deps.config.webDist)) {
registerStaticRoutes(app, deps.config.webDist);
+29 -1
View File
@@ -24,6 +24,14 @@ export interface ServerConfig {
dbPath: string;
/** Frontend static assets directory; whether it's enabled is decided by checking existence when the app is assembled. */
webDist: string;
/**
* Origin that serves Workspace HTML previews (PENGUIN_PREVIEW_ORIGIN), e.g.
* `https://preview.example.com`. It must differ from the App origin by **hostname** —
* cookies ignore ports, so a second port would still share the session cookie. Unset
* is the norm locally: the loopback counterpart (`127.0.0.1` <-> `localhost`) is
* derived per request instead. See design § "Workspace 文件预览".
*/
previewOrigin: string | null;
/** Login session validity period (7 days). */
authSessionTtlMs: number;
/** Sliding renewal threshold: if the remaining validity is below this value when validation succeeds, it's renewed to the full TTL (renews under 6 days). */
@@ -46,7 +54,26 @@ function defaultWebDist(): string {
return path.resolve(here, "..", "..", "web", "dist");
}
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB). */
/**
* Validates PENGUIN_PREVIEW_ORIGIN into a bare origin, or throws. An unparseable value
* is a hard failure rather than a silent fallback: falling back would quietly serve
* previews same-origin, which is the configuration this variable exists to avoid.
*/
function normalizePreviewOrigin(raw: string | undefined): string | null {
if (!raw || raw.trim() === "") return null;
let url: URL;
try {
url = new URL(raw.trim());
} catch {
throw new Error(`Invalid PENGUIN_PREVIEW_ORIGIN=${raw} (expected an absolute origin)`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`Invalid PENGUIN_PREVIEW_ORIGIN=${raw} (only http/https are supported)`);
}
return url.origin;
}
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN). */
export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
const root = env.PENGUIN_HOME ?? resolveRoot();
// An empty PORT string is treated as unset (the common `.env` case of an empty
@@ -62,6 +89,7 @@ export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): Serve
port,
dbPath: env.PENGUIN_WEB_DB ?? path.join(root, "web.db"),
webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
authSessionTtlMs: 7 * DAY_MS,
authSessionRenewMs: 6 * DAY_MS,
};
+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
+26 -1
View File
@@ -13,6 +13,7 @@ import { config as loadDotenv } from "dotenv";
import { serve } from "@hono/node-server";
import { buildAppDeps, createApp } from "./app.js";
import { resolveServerConfig } from "./config.js";
import { loopbackHostRoles } from "./services/preview-token.js";
loadDotenv({ quiet: true });
@@ -26,12 +27,35 @@ await deps.authService.seedAdmin();
// Schedule scheduler: startup reconciliation (missed, don't backfill) + periodic scan; only active while the server is running.
await deps.scheduler.start();
// On a loopback bind the App is canonicalized onto one name (`localhost`) and its
// counterpart is reserved for previews, so advertise the canonical name — the other one
// only 302s back here for App routes (see the canonical-host guard in app.ts).
const appHost = loopbackHostRoles(config.host)?.app ?? config.host;
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
console.log(`penguin-server started: http://${config.host}:${info.port}`);
console.log(`penguin-server started: http://${appHost}:${info.port}`);
console.log(`Data root: ${config.root}`);
console.log(`SQLite: ${config.dbPath}`);
});
/**
* Second loopback listener so the preview origin is actually reachable.
*
* Workspace HTML previews are served from the loopback counterpart of the host the App
* is used on (`127.0.0.1` <-> `localhost`, see design § "Workspace 文件预览"). On most
* systems `localhost` resolves to `::1` first, so a server bound only to `127.0.0.1`
* would leave every preview URL refusing connections. Binding `::1` as well closes that
* gap. Failure is non-fatal — the App keeps working, previews just fall back.
*/
const ipv6Loopback =
config.host === "127.0.0.1" || config.host === "localhost"
? serve({ fetch: app.fetch, hostname: "::1", port: config.port })
: null;
ipv6Loopback?.on("error", (err: NodeJS.ErrnoException) => {
console.warn(
`[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`,
);
});
let shuttingDown = false;
async function shutdown(signal: string, exitCode = 0): Promise<void> {
if (shuttingDown) return;
@@ -40,6 +64,7 @@ async function shutdown(signal: string, exitCode = 0): Promise<void> {
deps.scheduler.stop();
await deps.manager.shutdown(5000);
deps.channels.dispose();
ipv6Loopback?.close();
server.close(() => {
deps.db.close();
process.exit(exitCode);
@@ -0,0 +1,194 @@
/**
* Signed tokens for Workspace HTML preview on a separate origin.
*
* The preview origin deliberately differs from the App origin (see
* design/specs/05-ARCHITECTURE.md § "Workspace 文件预览"), so it never receives the
* session cookie — cookies are keyed by host and ignore port, which is why the two
* must differ by hostname and not merely by port. Authorization therefore travels in
* the URL as a short-lived HMAC token instead.
*
* The token binds three things: the Session whose Workspace may be read, the host the
* preview must be served from, and an expiry. Binding the host is what keeps this from
* becoming a same-origin XSS hole — the same process also answers on the App origin, so
* without that check `/preview/<token>/index.html` would happily execute Agent-written
* HTML with the App's cookies. See previewRoutes for the enforcement.
*
* The signing secret is generated per process: tokens are short-lived anyway, so losing
* them across a restart costs nothing and there is no secret to persist or rotate.
*/
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/** Preview tokens live just long enough to open a page and let it pull its subresources. */
export const PREVIEW_TOKEN_TTL_MS = 10 * 60 * 1000;
export interface PreviewTokenPayload {
/** Session whose Workspace subtree the token grants read access to. */
sessionId: string;
/** Host (no port) the preview must be served from; anything else is refused. */
host: string;
/** Expiry, epoch milliseconds. */
expiresAt: number;
}
/** Wire form: `<base64url(json)>.<base64url(hmac)>`. */
type Signer = {
sign(payload: PreviewTokenPayload): string;
verify(token: string): PreviewTokenPayload | null;
};
function b64url(buf: Buffer): string {
return buf.toString("base64url");
}
export function createPreviewTokenSigner(secret: Buffer = randomBytes(32)): Signer {
const mac = (body: string): Buffer => createHmac("sha256", secret).update(body).digest();
return {
sign(payload) {
const body = b64url(Buffer.from(JSON.stringify(payload), "utf8"));
return `${body}.${b64url(mac(body))}`;
},
verify(token) {
const dot = token.indexOf(".");
if (dot <= 0 || dot === token.length - 1) return null;
const body = token.slice(0, dot);
const provided = Buffer.from(token.slice(dot + 1), "base64url");
const expected = mac(body);
// Length check first: timingSafeEqual throws on a length mismatch.
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return null;
let payload: PreviewTokenPayload;
try {
payload = JSON.parse(
Buffer.from(body, "base64url").toString("utf8"),
) as PreviewTokenPayload;
} catch {
return null;
}
if (
typeof payload?.sessionId !== "string" ||
typeof payload?.host !== "string" ||
typeof payload?.expiresAt !== "number"
) {
return null;
}
if (Date.now() >= payload.expiresAt) return null;
return payload;
},
};
}
export type PreviewTokenSigner = ReturnType<typeof createPreviewTokenSigner>;
/** Host header without its port (IPv6 literals keep their brackets). */
export function hostOnly(hostHeader: string): string {
const trimmed = hostHeader.trim();
if (trimmed.startsWith("[")) {
const end = trimmed.indexOf("]");
return end === -1 ? trimmed : trimmed.slice(0, end + 1);
}
const colon = trimmed.lastIndexOf(":");
return colon === -1 ? trimmed : trimmed.slice(0, colon);
}
/**
* The loopback counterpart of the host the App is being used on: `127.0.0.1` and
* `localhost` are distinct hosts for cookie purposes, so serving previews from the
* other one isolates them with no extra port, no DNS, and nothing to configure.
* Returns null for any other host (LAN IP, real domain) — those need an explicit
* PENGUIN_PREVIEW_ORIGIN, and the caller degrades to the same-origin sandbox.
*/
export function loopbackCounterpart(host: string): string | null {
const h = host.toLowerCase();
if (h === "127.0.0.1") return "localhost";
if (h === "localhost") return "127.0.0.1";
// ::1 shares localhost's cookie jar semantics poorly across browsers; send it to the
// IPv4 literal, which is unambiguous.
if (h === "[::1]") return "127.0.0.1";
return null;
}
/**
* When the server is bound to a loopback host, the App is canonicalized onto one loopback
* name and previews are served from the other. Fixing the assignment — rather than serving
* previews from the counterpart of whichever name the caller happens to type — is what
* keeps the preview host free of a session cookie. The same process answers on both names,
* so if the App could be reached (and logged into) on the preview name too, Agent-written
* preview HTML would call `/api` same-origin and regain a full App session. `app` is the
* only name that serves the App and sets/accepts the session cookie; `preview` serves
* `/preview/*` and nothing else (enforced by the canonical-host guard in app.ts).
*
* Null for non-loopback binds (wildcard, LAN IP, a bare `::1`): they can't offer a loopback
* preview the IPv6 companion listener actually serves, so they must set
* PENGUIN_PREVIEW_ORIGIN and otherwise fall back to the same-origin sandbox.
*/
export function loopbackHostRoles(bindHost: string): { app: string; preview: string } | null {
const h = bindHost.trim().toLowerCase();
if (h !== "127.0.0.1" && h !== "localhost") return null;
const app = "localhost";
const preview = loopbackCounterpart(app);
if (!preview) return null;
return { app, preview };
}
/**
* The host:port this request was addressed to. Prefers the Host header (what the browser
* actually sent, and what decides the origin), falling back to the request URL's
* authority — some server runtimes and test harnesses build a Request from a full URL
* without materializing a Host header.
*/
export function requestAuthority(requestUrl: string, hostHeader: string | undefined): string {
const header = (hostHeader ?? "").trim();
if (header !== "") return header;
try {
return new URL(requestUrl).host;
} catch {
return "";
}
}
/**
* Where previews for this request should be served from: the configured origin when
* PENGUIN_PREVIEW_ORIGIN is set, otherwise the loopback counterpart of the host the
* caller is using. Null when neither applies — the caller degrades to the same-origin
* sandbox rather than silently serving unisolated content.
*
* The port comes from **this server's own binding**, never from the request. Those differ
* in development: the SPA is served by Vite on its own port and only proxies `/api`, so a
* preview URL built from the browser's current port would point at a port where nothing
* serves `/preview` — connection refused, or a Vite 404. In production the two are the
* same and this is a no-op. Only a loopback-name bind (`127.0.0.1` / `localhost`) offers a
* loopback preview — it is the bind where the IPv6 companion listener runs and where the
* App is canonicalized (see `loopbackHostRoles`); any other bind (wildcard, LAN IP) has no
* reachable counterpart and falls back.
*/
export function resolvePreviewTarget(
requestUrl: string,
hostHeader: string | undefined,
configuredOrigin: string | null,
serverBind: { host: string; port: number },
): { origin: string; host: string } | null {
if (configuredOrigin) {
const url = new URL(configuredOrigin);
return { origin: url.origin, host: url.hostname };
}
const raw = requestAuthority(requestUrl, hostHeader);
if (raw === "") return null;
const counterpart = loopbackCounterpart(hostOnly(raw));
if (!counterpart) return null;
if (!loopbackHostRoles(serverBind.host)) return null;
let protocol: string;
try {
protocol = new URL(requestUrl).protocol;
} catch {
protocol = "http:";
}
const suffix =
(protocol === "http:" && serverBind.port === 80) ||
(protocol === "https:" && serverBind.port === 443)
? ""
: `:${serverBind.port}`;
return { origin: `${protocol}//${counterpart}${suffix}`, host: counterpart };
}
+8 -3
View File
@@ -25,8 +25,11 @@ export function testConfig(root: string): ServerConfig {
return {
root,
host: "127.0.0.1",
port: 0,
// Nothing listens in tests, but the value is not inert: preview URLs are built from
// the server's own port, so keep it realistic rather than 0.
port: 7364,
dbPath: ":memory:",
previewOrigin: null,
// Points to a nonexistent directory: static hosting is disabled in tests.
webDist: path.join(root, "__no_web_dist__"),
authSessionTtlMs: 7 * DAY_MS,
@@ -44,13 +47,15 @@ export interface TestApp {
export interface TestAppOptions extends BuildDepsOverrides {
/** Runs before seeding the admin (for scenarios pre-populating a default_project config as the CLI would). */
beforeSeed?: (root: string) => Promise<void>;
/** Overrides merged onto the default test ServerConfig (e.g. `previewOrigin`). */
config?: Partial<ServerConfig>;
}
export async function createTestApp(options: TestAppOptions = {}): Promise<TestApp> {
const { beforeSeed, ...overrides } = options;
const { beforeSeed, config, ...overrides } = options;
const root = await makeTempRoot();
if (beforeSeed) await beforeSeed(root);
const deps = buildAppDeps(testConfig(root), { log: () => {}, ...overrides });
const deps = buildAppDeps({ ...testConfig(root), ...config }, { log: () => {}, ...overrides });
// Consistent with the startup entrypoint: seed the built-in admin (owning default_project).
await deps.authService.seedAdmin();
const app = createApp(deps);
@@ -0,0 +1,266 @@
/**
* 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();
});
});
describe("preview route", () => {
let t: TestApp;
let owner: ReturnType<typeof apiClient>;
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"), "<!doctype html><script src=app.js>");
await fs.writeFile(path.join(workspace, "assets", "app.js"), "console.log(1)");
});
afterEach(async () => {
await t.cleanup();
});
/**
* Follow the "open in a new tab" link the way a browser would; app.request() addresses
* the App on localhost, so the counterpart origin is 127.0.0.1.
*/
const mint = async (rel: string): Promise<{ url: string | null; status: number }> => {
const res = await owner.get(
`/api/sessions/${sessionId}/files/preview-redirect?path=${encodeURIComponent(rel)}`,
);
return { url: res.headers.get("location"), status: res.status };
};
it("redirects to the loopback counterpart of the App host", async () => {
const { url, status } = await mint("index.html");
expect(status).toBe(302);
expect(url).toMatch(/^http:\/\/127\.0\.0\.1:7364\/preview\/[^/]+\/index\.html$/);
});
it("serves the file with a real content type, no sandbox, and no referrer", async () => {
const { url } = await mint("index.html");
const res = await t.app.request(url!);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/html");
// The origin is the boundary now, so the sandbox that broke storage is gone.
expect(res.headers.get("content-security-policy")).toBeNull();
// Without this the token-bearing URL leaks to every third-party the page embeds.
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
});
it("resolves relative subresources under the same token", async () => {
const { url } = await mint("index.html");
const token = url!.split("/preview/")[1]!.split("/")[0]!;
const res = await t.app.request(`http://127.0.0.1/preview/${token}/assets/app.js`);
expect(res.status).toBe(200);
expect(await res.text()).toBe("console.log(1)");
});
it("refuses to serve on the App origin's host", async () => {
const { url } = await mint("index.html");
const token = url!.split("/preview/")[1]!.split("/")[0]!;
// Same token, same path, but Host is the App origin: serving here would run
// Agent-written HTML same-origin with the session cookie.
const res = await t.app.request(`http://localhost/preview/${token}/index.html`);
expect(res.status).toBe(404);
});
it("rejects a garbage token and keeps path confinement", async () => {
expect((await t.app.request("http://127.0.0.1/preview/nope/index.html")).status).toBe(404);
const { url } = await mint("index.html");
const token = url!.split("/preview/")[1]!.split("/")[0]!;
// Traversal with encoded slashes: WHATWG URL parsing collapses `../` — and even
// `%2e%2e/` — before the handler sees it, so encode only the slashes. The `..` segments
// then survive as one path segment, and decodeURIComponent -> WorkspaceFilesService is
// what rejects them. (A plain `../../etc/passwd` would instead normalize to `/etc/passwd`
// and never match the /preview route at all.)
const escape = await t.app.request(`http://127.0.0.1/preview/${token}/..%2f..%2fetc%2fpasswd`);
expect(escape.status).toBe(404);
});
it("serves only /preview on the preview host: /api is 401 even authenticated", async () => {
// The preview host (127.0.0.1) and the App (localhost) are one process. If /api answered
// here, Agent HTML previewed on 127.0.0.1 could drive it same-origin — so it is refused
// even with a valid cookie, and no cookie is ever set here either.
const api = await t.app.request(`http://127.0.0.1/api/sessions/${sessionId}/files?path=`, {
headers: { cookie: ownerCookie },
});
expect(api.status).toBe(401);
// A top-level App navigation on the preview host is redirected to the canonical host, so
// login (and every cookie) only ever happens on localhost.
const doc = await t.app.request("http://127.0.0.1/some/spa/route");
expect(doc.status).toBe(302);
expect(doc.headers.get("location")).toBe("http://localhost/some/spa/route");
});
it("requires authentication to mint, and validates the path", async () => {
const anon = await t.app.request(
`/api/sessions/${sessionId}/files/preview-redirect?path=index.html`,
);
expect(anon.status).toBe(401);
const { status } = await mint("nope.html");
expect(status).toBe(404);
});
it("reports isolation on /api/me so the UI can warn before opening", async () => {
const me = (await (await owner.get("/api/me")).json()) as { previewIsolated: boolean };
expect(me.previewIsolated).toBe(true);
});
it("leaves the loopback guard off when PENGUIN_PREVIEW_ORIGIN is set", async () => {
// With a configured preview origin, previews no longer use 127.0.0.1, so it is an
// ordinary App host and must not be redirected/refused (deployments enforce the
// equivalent at their reverse proxy). A guarded app would 302 this to localhost.
const configured = await createTestApp({ config: { previewOrigin: "https://p.example.com" } });
try {
const doc = await configured.app.request("http://127.0.0.1/some/spa/route");
expect(doc.status).not.toBe(302);
} finally {
await configured.cleanup();
}
});
});
+14 -2
View File
@@ -337,9 +337,21 @@ export const listWorkspaceFiles = (sessionId: string, path: string) =>
export const workspaceFileUrl = (sessionId: string, path: string, download = false): string =>
`/api/sessions/${sessionId}/files/content?path=${encodeURIComponent(path)}${download ? "&download=1" : ""}`;
/** Sandboxed top-level preview URL (open an html file in a new tab): real content type under a CSP sandbox, see the server route. */
/**
* "Open in a new tab" for a Workspace html file: an App-origin link that mints a signed
* token and 302s to the separate preview origin, where the page gets a real origin with
* working storage, cookies and third-party embeds.
*
* A link (not a fetch + `window.open`) on purpose — opening a tab after an await trips
* popup blockers, and a script-opened window keeps an `opener` handle back to the App,
* which is precisely the reference the separate origin exists to deny. Use it with
* `rel="noopener noreferrer"`.
*
* Falls back server-side to the sandboxed same-origin preview when the deployment has no
* usable preview origin; `previewIsolated` from /api/me says so in advance.
*/
export const workspaceFilePreviewUrl = (sessionId: string, path: string): string =>
`/api/sessions/${sessionId}/files/content?path=${encodeURIComponent(path)}&preview=1`;
`/api/sessions/${sessionId}/files/preview-redirect?path=${encodeURIComponent(path)}`;
export const uploadWorkspaceFile = (sessionId: string, path: string, dataBase64: string) =>
apiFetch<void>(`/api/sessions/${sessionId}/files/content`, {
@@ -19,6 +19,7 @@ import remarkGfm from "remark-gfm";
import type { SessionInfo, WorkspaceFilesResponse } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { useAuth } from "../../state/auth";
import { S } from "../../lib/strings";
import { formatBytes, formatDateTime } from "../../lib/format";
import { Button } from "../../components/ui/button";
@@ -187,6 +188,9 @@ export function WorkspaceBrowser({
/** Callback when entering file preview (used by the mobile Sheet to raise its snap point to full). */
onPreviewOpen?: () => void;
}) {
// Whether "open in new tab" lands on a separate origin; false downgrades it to the
// same-origin sandbox, which the link flags rather than failing silently in the page.
const { previewIsolated } = useAuth();
const [path, setPath] = useState("");
const [data, setData] = useState<WorkspaceFilesResponse | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -402,14 +406,26 @@ export function WorkspaceBrowser({
</div>
)}
{/* Ghost style, matching the toolbar's upload label (text-xs, transparent until hover) — the bordered secondary look stood out from every neighbor. */}
{/* rel="noopener noreferrer" is load-bearing, not boilerplate: the preview must
not keep a handle back to this window, which is the whole point of serving
it from a separate origin. */}
{/\.html?$/i.test(preview.name) && (
<a
href={api.workspaceFilePreviewUrl(session.sessionId, preview.path)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center rounded-md border border-transparent bg-transparent px-2.5 py-1 text-xs font-medium text-gray-600 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
title={previewIsolated ? undefined : S.files.previewNotIsolatedHint}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-transparent bg-transparent px-2.5 py-1 text-xs font-medium text-gray-600 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
>
{S.files.openInNewTab}
{!previewIsolated && (
<span
aria-label={S.files.previewNotIsolatedHint}
className="text-amber-600 dark:text-amber-500"
>
⚠
</span>
)}
</a>
)}
<a
+2
View File
@@ -713,6 +713,8 @@ When done, open index.html in a browser and self-test once.`,
upload: "Upload",
download: "Download",
openInNewTab: "Open in new tab",
previewNotIsolatedHint:
"This address has no separate preview origin, so the page opens sandboxed: localStorage, cookies and third-party embeds will not work. Reach the app over 127.0.0.1 or localhost, or set PENGUIN_PREVIEW_ORIGIN.",
refresh: "Refresh",
root: "Workspace root",
empty: "Empty directory",
+2
View File
@@ -697,6 +697,8 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
upload: "上传",
download: "下载",
openInNewTab: "新页面打开",
previewNotIsolatedHint:
"当前访问地址无法提供独立预览源,页面将以沙箱模式打开:localStorage、Cookie 与第三方 embed 不可用。经 127.0.0.1 或 localhost 访问,或配置 PENGUIN_PREVIEW_ORIGIN 即可解除。",
refresh: "刷新",
root: "根目录",
empty: "空目录",
+17 -2
View File
@@ -13,6 +13,13 @@ import { ApiError, setUnauthorizedHandler } from "../api/client";
interface AuthContextValue {
/** undefined = initializing; null = not logged in. */
user: UserInfo | null | undefined;
/**
* Whether Workspace HTML previews open on a separate origin. False means this
* deployment falls back to the same-origin sandbox, where `localStorage`, cookies and
* third-party embeds do not work — the Files panel warns before opening. Comes from
* /api/me because it depends on the host the browser is using.
*/
previewIsolated: boolean;
login: (userId: string, password: string) => Promise<void>;
logout: () => Promise<void>;
/** Refetch /api/me (e.g. to refresh the passwordIsInitial flag after a password change). */
@@ -23,6 +30,9 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserInfo | null | undefined>(undefined);
// Assume isolated until told otherwise: the warning is the exceptional state, and
// flashing it during initialization would be noise.
const [previewIsolated, setPreviewIsolated] = useState(true);
// Any API returning 401 (session expired / database rebuilt) clears the current user, and
// RequireAuth redirects back to the login page.
@@ -38,7 +48,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
api
.getMe()
.then((res) => {
if (!cancelled) setUser(res.user);
if (cancelled) return;
setUser(res.user);
setPreviewIsolated(res.previewIsolated);
})
.catch((err: unknown) => {
if (cancelled) return;
@@ -66,10 +78,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const refresh = useCallback(async () => {
const res = await api.getMe();
setUser(res.user);
setPreviewIsolated(res.previewIsolated);
}, []);
return (
<AuthContext.Provider value={{ user, login, logout, refresh }}>{children}</AuthContext.Provider>
<AuthContext.Provider value={{ user, previewIsolated, login, logout, refresh }}>
{children}
</AuthContext.Provider>
);
}