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
+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 };
}