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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user