Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/packages/studio-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ Studio reads the flag once, when its modules load, so set
`window.__HF_STUDIO_ENV__` before loading Studio; the CLI injects it, but a host
that mounts the API itself must inject it too.

The API is unauthenticated, so state-changing requests are checked against
the request's own origin, scheme included: a write is accepted only when it
comes from the Studio page this server serves. Loopback works with no setup.
If you mount the API on a public host name (your own domain, a tunnel, a
Codespaces address), name it in `allowedHosts` so writes from that origin
pass:

```ts
app.route(
"/api",
createStudioApi(adapter, { allowedHosts: ["studio.example.com"] }),
);
```

A `StudioApiAdapter` supplies:

- project listing and resolution;
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/server/desktopRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { desktopDownloadUrl, type DesktopOpenResult } from "../utils/desktopApp.js";
import { mountDesktopRoutes, sameOriginPost } from "./desktopRoutes.js";
import { mountDesktopRoutes } from "./desktopRoutes.js";
import { sameOriginPost } from "@hyperframes/studio-server/request-identity";

const STUDIO = {
host: "localhost:3002",
Expand Down
26 changes: 13 additions & 13 deletions packages/cli/src/server/desktopRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,19 @@ import {
desktopInstalled,
openInDesktop,
} from "../utils/desktopApp.js";
import { identityAllowed } from "./telemetryIdentity.js";
import { sameOriginPost } from "@hyperframes/studio-server/request-identity";

const CTA_ID = /^[A-Za-z0-9-]{1,64}$/;

/** A bodiless POST is a simple request, so any page can send one to localhost: it has to come from this Studio. */
export function sameOriginPost(headers: {
host?: string;
origin?: string;
fetchSite?: string;
}): boolean {
const { host, origin, fetchSite } = headers;
if (!host || !identityAllowed(host)) return false;
if (fetchSite && fetchSite !== "same-origin") return false;
return origin === undefined || origin === `http://${host}`;
/** Scheme of a request URL ("http" or "https"), or undefined when it does not parse. */
function urlSchemeOf(url: string): string | undefined {
try {
return new URL(url).protocol.replace(/:$/, "") || undefined;
} catch {
return undefined;
}
}

const CTA_ID = /^[A-Za-z0-9-]{1,64}$/;

/** The desktop-app button's route: show it or not, the download, and whether the app here takes the project. */
export function mountDesktopRoutes(
app: Hono,
Expand Down Expand Up @@ -48,6 +45,9 @@ export function mountDesktopRoutes(
host: c.req.header("host"),
origin: c.req.header("origin"),
fetchSite: c.req.header("sec-fetch-site"),
proto: urlSchemeOf(c.req.url),
forwardedProto: c.req.header("x-forwarded-proto"),
forwardedHost: c.req.header("x-forwarded-host"),
});
if (!allowed) return c.json({ error: "forbidden" }, 403);
if (!ready)
Expand Down
133 changes: 15 additions & 118 deletions packages/cli/src/server/telemetryIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
// server's heavy render dependencies (@hyperframes/producer, engine, …).
// ---------------------------------------------------------------------------

import { hostname, networkInterfaces } from "node:os";
import { identityAllowed, isLoopbackHost } from "@hyperframes/studio-server/request-identity";
import { readConfig, readConfigFresh } from "../telemetry/config.js";
import {
resetTelemetryPostureCache,
Expand All @@ -25,6 +25,12 @@ import {
import { canaryDecisionsForStudio, type CliCanaryDecision } from "../telemetry/canary.js";
import { detectAgentRuntime } from "../telemetry/agent_runtime.js";

// The host-identity half of this module moved to
// @hyperframes/studio-server/request-identity, where the studio API's
// write guard shares the same definition. Re-exported so this module's
// existing importers keep one source for it.
export { identityAllowed, isLoopbackHost };

/**
* The CLI's anonymous distinct id to hand to Studio, or null when CLI telemetry
* is disabled or no id is available. Fail-silent — telemetry must never break
Expand Down Expand Up @@ -58,33 +64,6 @@ function resolveCliBucketSeed(): string | null {
}
}

/**
* Is this request's `Host` a loopback name the studio server could have been
* reached on directly?
*
* A bare `[::1]`/`localhost`/dotted-quad check rather than a full parse: the
* port is irrelevant (any port on loopback is us), and anything exotic enough
* to miss here should be refused rather than guessed at.
*
* Scope, stated precisely: this is a **DNS-rebinding mitigation for browsers**,
* not access control. A browser sets `Host` from the URL it was given, so a
* page that rebinds its own hostname to 127.0.0.1 arrives carrying that
* hostname and is refused. A non-browser client sets `Host` to whatever it
* likes, so this stops nothing there — but on a loopback-bound server such a
* client is already local, and on a LAN-bound one it can read the project
* files through the unauthenticated studio API anyway. See `identityAllowed`.
*/
export function isLoopbackHost(host: string | undefined): boolean {
if (!host) return false;
// Strip the port. IPv6 literals are bracketed (`[::1]:1234`), so take the
// bracketed part when present and only split on ":" otherwise.
const bracketed = /^\[([^\]]+)\]/.exec(host);
const hostname = (bracketed ? bracketed[1] : host.split(":")[0])?.toLowerCase() ?? "";
if (hostname === "localhost" || hostname === "::1" || hostname === "0:0:0:0:0:0:0:1") return true;
// 127.0.0.0/8 — the whole loopback block, not just 127.0.0.1.
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
}

// JSON.stringify does not escape "<" or "/". Escaping both means no
// "</script>" (or "</…") sequence can form in the emitted value, so it can
// never terminate the inline <script> or open a new tag. (The values are
Expand Down Expand Up @@ -197,96 +176,6 @@ export function buildStudioHeadScripts(
return `${buildCliIdentityScript(options)}${envScript}`;
}

/**
* The `<head>` scripts for a request, given its `Host`.
*
* The Host split lives here rather than in the route so it is testable
* without a Studio bundle on disk — the route's own test can only reach the
* injection branch when `packages/studio/dist` happens to be built, which is
* true locally and false in the CI test lane.
*/
/** Hostname of a `Host` header, port and IPv6 brackets removed. */
function hostnameOf(host: string | undefined): string {
if (!host) return "";
const bracketed = /^\[([^\]]+)\]/.exec(host);
return (bracketed ? bracketed[1] : host.split(":")[0])?.toLowerCase() ?? "";
}

/**
* Names this machine legitimately answers to on a wildcard bind: every
* interface address, plus its own hostname and the `.local` mDNS form people
* actually type. Fail closed — a throw yields an empty set, which denies.
*/
function localNames(): Set<string> {
const names = new Set<string>();
try {
for (const entries of Object.values(networkInterfaces())) {
for (const entry of entries ?? []) names.add(entry.address.toLowerCase());
}
const self = hostname().toLowerCase();
if (self !== "") {
names.add(self);
// `my-box` is reachable as `my-box.local`, and a `my-box.lan.example`
// FQDN is reachable by its short form. Register both directions.
names.add(`${self}.local`);
const short = self.split(".")[0];
if (short !== undefined && short !== "") {
names.add(short);
names.add(`${short}.local`);
}
}
} catch {
/* fail closed */
}
return names;
}

/**
* Wildcard binds answer on every local name; a specific bind on itself only.
*
* This is the part that was missing: the previous rule accepted ANY Host once
* the env var was set, so a rebinding page could name itself `evil.example.com`
* and still be handed the CLI identity.
*/
function hostMatchesBind(host: string | undefined, bind: string): boolean {
const requested = hostnameOf(host);
if (requested === "") return false;
const bound = bind.toLowerCase();
if (requested === bound) return true;
return bound === "0.0.0.0" || bound === "::" || bound === "*"
? localNames().has(requested)
: false;
}

/**
* May this request receive the CLI's identity (distinct id + bucket seed)?
*
* The server binds loopback by DEFAULT and exposes the LAN only when an
* operator sets `HYPERFRAMES_PREVIEW_HOST` (portUtils.ts, F-001). A loopback
* `Host` is always fine: whatever reached us came via loopback, and the only
* interesting attacker is a rebinding browser page — which the Host check
* catches, because a browser cannot forge `Host`.
*
* For anything else the bind decides:
*
* - **Unset, or bound to loopback.** No LAN exposure was requested, so the
* Host check is a live rebinding mitigation and a non-loopback Host is
* refused. Previously ANY non-empty value disabled the check, so even
* `HYPERFRAMES_PREVIEW_HOST=127.0.0.1` — which exposes nothing — turned the
* loopback service from hostile-Host refusal into accept-everything.
* - **Bound to a LAN address.** The operator opted into exposure, so identity
* has to reach the LAN name the user browses or the CLI-to-Studio stitch
* breaks and Studio mints a second person for the same human. But it is
* still checked: the Host must name an address this machine actually
* answers on, not any attacker-chosen name a rebinding page supplies.
*/
export function identityAllowed(host: string | undefined): boolean {
if (isLoopbackHost(host)) return true;
const bind = (process.env["HYPERFRAMES_PREVIEW_HOST"] ?? "").trim();
if (bind === "" || isLoopbackHost(bind)) return false;
return hostMatchesBind(host, bind);
}

/**
* Re-read the persisted telemetry preference for this request.
*
Expand All @@ -309,6 +198,14 @@ export function refreshTelemetryPosture(): void {
}
}

/**
* The `<head>` scripts for a request, given its `Host`.
*
* The Host split lives here rather than in the route so it is testable
* without a Studio bundle on disk — the route's own test can only reach the
* injection branch when `packages/studio/dist` happens to be built, which is
* true locally and false in the CI test lane.
*/
export function buildStudioHeadScriptsForHost(envScript: string, host: string | undefined): string {
refreshTelemetryPosture();
return buildStudioHeadScripts(envScript, { includeIdentity: identityAllowed(host) });
Expand Down
6 changes: 6 additions & 0 deletions packages/studio-server/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
"types": "./dist/helpers/safePath.d.ts",
"environments": ["bun", "node"]
},
"./request-identity": {
"source": "./src/helpers/requestIdentity.ts",
"runtime": "./dist/helpers/requestIdentity.js",
"types": "./dist/helpers/requestIdentity.d.ts",
"environments": ["bun", "node"]
},
"./hf-ids": {
"source": "./src/helpers/hfIdPersist.ts",
"runtime": "./dist/helpers/hfIdPersist.js",
Expand Down
10 changes: 10 additions & 0 deletions packages/studio-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
"import": "./src/helpers/safePath.ts",
"types": "./src/helpers/safePath.ts"
},
"./request-identity": {
"bun": "./src/helpers/requestIdentity.ts",
"node": "./dist/helpers/requestIdentity.js",
"import": "./src/helpers/requestIdentity.ts",
"types": "./src/helpers/requestIdentity.ts"
},
"./hf-ids": {
"bun": "./src/helpers/hfIdPersist.ts",
"node": "./dist/helpers/hfIdPersist.js",
Expand Down Expand Up @@ -122,6 +128,10 @@
"import": "./dist/helpers/safePath.js",
"types": "./dist/helpers/safePath.d.ts"
},
"./request-identity": {
"import": "./dist/helpers/requestIdentity.js",
"types": "./dist/helpers/requestIdentity.d.ts"
},
"./hf-ids": {
"import": "./dist/helpers/hfIdPersist.js",
"types": "./dist/helpers/hfIdPersist.d.ts"
Expand Down
Loading