diff --git a/docs/packages/studio-server.mdx b/docs/packages/studio-server.mdx index 640e199f276..fab0f7e21c0 100644 --- a/docs/packages/studio-server.mdx +++ b/docs/packages/studio-server.mdx @@ -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; diff --git a/packages/cli/src/server/desktopRoutes.test.ts b/packages/cli/src/server/desktopRoutes.test.ts index c583de0aac0..831fa1bbbad 100644 --- a/packages/cli/src/server/desktopRoutes.test.ts +++ b/packages/cli/src/server/desktopRoutes.test.ts @@ -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", diff --git a/packages/cli/src/server/desktopRoutes.ts b/packages/cli/src/server/desktopRoutes.ts index 54fc22023ec..ad55f92a806 100644 --- a/packages/cli/src/server/desktopRoutes.ts +++ b/packages/cli/src/server/desktopRoutes.ts @@ -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, @@ -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) diff --git a/packages/cli/src/server/telemetryIdentity.ts b/packages/cli/src/server/telemetryIdentity.ts index 0f76bf983bd..6ed36658f07 100644 --- a/packages/cli/src/server/telemetryIdentity.ts +++ b/packages/cli/src/server/telemetryIdentity.ts @@ -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, @@ -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 @@ -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 // "" (or " or open a new tag. (The values are @@ -197,96 +176,6 @@ export function buildStudioHeadScripts( return `${buildCliIdentityScript(options)}${envScript}`; } -/** - * The `` 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 { - const names = new Set(); - 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. * @@ -309,6 +198,14 @@ export function refreshTelemetryPosture(): void { } } +/** + * The `` 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) }); diff --git a/packages/studio-server/package-subpaths.json b/packages/studio-server/package-subpaths.json index 5a92364153d..be7345ac0f2 100644 --- a/packages/studio-server/package-subpaths.json +++ b/packages/studio-server/package-subpaths.json @@ -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", diff --git a/packages/studio-server/package.json b/packages/studio-server/package.json index 28343b4d13e..a70711d2021 100644 --- a/packages/studio-server/package.json +++ b/packages/studio-server/package.json @@ -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", @@ -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" diff --git a/packages/studio-server/src/createStudioApi.originGuard.test.ts b/packages/studio-server/src/createStudioApi.originGuard.test.ts new file mode 100644 index 00000000000..5601fa4e58e --- /dev/null +++ b/packages/studio-server/src/createStudioApi.originGuard.test.ts @@ -0,0 +1,250 @@ +// @vitest-environment node +// Node environment on purpose: the package default (happy-dom) implements +// the browser fetch spec, which drops forbidden request headers such as +// Origin and Sec-Fetch-Site when a Request is constructed. A test that +// simulates cross-origin browser traffic has to set exactly those headers, +// so it needs Node's fetch classes, which keep them. +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createStudioApi } from "./createStudioApi"; +import { stubAdapter } from "./routes/stubAdapter.test-helpers"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function createApp(): { app: ReturnType; projectDir: string } { + const projectDir = mkdtempSync(join(tmpdir(), "hf-origin-guard-")); + tempDirs.push(projectDir); + return { app: createStudioApi(stubAdapter(projectDir)), projectDir }; +} + +const EVIL_ORIGIN = "http://evil.example:9999"; + +function createFile( + app: ReturnType, + headers: Record, + name = "planted.html", +): Promise { + // A real browser request always carries a Host header; app.request does + // not derive one in this environment, so send it explicitly. That keeps + // every case exercising the Origin comparison itself. + return app.request(`http://localhost/projects/demo/files/${name}`, { + method: "POST", + headers: { Host: "localhost", ...headers }, + body: "planted", + }); +} + +describe("createStudioApi cross-origin write guard", () => { + it("rejects a cross-origin POST file create and writes nothing", async () => { + const { app, projectDir } = createApp(); + const res = await createFile(app, { Origin: EVIL_ORIGIN, "Content-Type": "text/plain" }); + expect(res.status).toBe(403); + expect(existsSync(join(projectDir, "planted.html"))).toBe(false); + }); + + it("rejects a cross-origin multipart upload", async () => { + const { app, projectDir } = createApp(); + const form = new FormData(); + form.append("files", new File(["planted"], "planted.html", { type: "text/html" })); + const res = await app.request("http://localhost/projects/demo/upload", { + method: "POST", + headers: { Host: "localhost", Origin: EVIL_ORIGIN }, + body: form, + }); + expect(res.status).toBe(403); + expect(existsSync(join(projectDir, "planted.html"))).toBe(false); + }); + + it("rejects a write whose Origin is the opaque literal null", async () => { + const { app } = createApp(); + const res = await createFile(app, { Origin: "null", "Content-Type": "text/plain" }); + expect(res.status).toBe(403); + }); + + it("rejects a write marked cross-site by Fetch Metadata even without Origin", async () => { + const { app } = createApp(); + const res = await createFile(app, { "Sec-Fetch-Site": "cross-site" }); + expect(res.status).toBe(403); + }); + + it("rejects a cross-origin PUT overwrite", async () => { + const { app } = createApp(); + const res = await app.request("http://localhost/projects/demo/files/planted.html", { + method: "PUT", + headers: { Host: "localhost", Origin: EVIL_ORIGIN, "If-None-Match": "*" }, + body: "overwritten", + }); + expect(res.status).toBe(403); + }); + + it("rejects a write from a DNS name pointing at loopback, even when Origin and Host match", async () => { + // The attacker's page is served from a name that resolves to 127.0.0.1, + // so its Origin and Host agree with each other and a guard that only + // compares the pair lets it through. The shared predicate also requires + // the Host to be an identity this server answers to, and + // "rebind.example" is not one, whatever it resolves to. + const { app, projectDir } = createApp(); + const res = await createFile(app, { + Host: "rebind.example:9999", + Origin: "http://rebind.example:9999", + "Sec-Fetch-Site": "same-origin", + "Content-Type": "text/plain", + }); + expect(res.status).toBe(403); + expect(existsSync(join(projectDir, "planted.html"))).toBe(false); + }); + + it("rejects a write with no Origin when the Host is a DNS name, not a server identity", async () => { + const { app, projectDir } = createApp(); + const res = await createFile(app, { Host: "rebind.example:9999" }); + expect(res.status).toBe(403); + expect(existsSync(join(projectDir, "planted.html"))).toBe(false); + }); + + it("rejects a same-site write even when Origin and Host match", async () => { + const { app } = createApp(); + const res = await createFile(app, { + Origin: "http://localhost", + "Sec-Fetch-Site": "same-site", + "Content-Type": "text/plain", + }); + expect(res.status).toBe(403); + }); + + it("allows a same-origin write (Origin host matches Host)", async () => { + const { app, projectDir } = createApp(); + const res = await createFile(app, { + Origin: "http://localhost", + "Content-Type": "text/plain", + }); + expect(res.status).toBe(201); + expect(existsSync(join(projectDir, "planted.html"))).toBe(true); + }); + + it("allows a write with no Origin header (non-browser callers)", async () => { + const { app, projectDir } = createApp(); + const res = await createFile(app, { "Content-Type": "text/plain" }); + expect(res.status).toBe(201); + expect(existsSync(join(projectDir, "planted.html"))).toBe(true); + }); + + it("does not block reads from any origin", async () => { + const { app } = createApp(); + await createFile(app, { "Content-Type": "text/plain" }); + const res = await app.request("http://localhost/projects/demo/files/planted.html", { + headers: { Origin: EVIL_ORIGIN }, + }); + expect(res.status).toBe(200); + }); + + it("allows an HTTPS same-origin write", async () => { + const { app, projectDir } = createApp(); + const res = await app.request("https://localhost:3002/projects/demo/files/planted.html", { + method: "POST", + headers: { + Host: "localhost:3002", + Origin: "https://localhost:3002", + "Content-Type": "text/plain", + }, + body: "planted", + }); + expect(res.status).toBe(201); + expect(existsSync(join(projectDir, "planted.html"))).toBe(true); + }); + + it("allows an HTTPS write behind a TLS proxy that forwards the proto", async () => { + const { app, projectDir } = createApp(); + const res = await app.request("http://localhost:3002/projects/demo/files/planted.html", { + method: "POST", + headers: { + Host: "localhost:3002", + Origin: "https://localhost:3002", + "X-Forwarded-Proto": "https", + "Content-Type": "text/plain", + }, + body: "planted", + }); + expect(res.status).toBe(201); + expect(existsSync(join(projectDir, "planted.html"))).toBe(true); + }); + + it("rejects an HTTPS Origin on a request that arrived over plain HTTP", async () => { + const { app, projectDir } = createApp(); + const res = await app.request("http://localhost:3002/projects/demo/files/planted.html", { + method: "POST", + headers: { + Host: "localhost:3002", + Origin: "https://localhost:3002", + "Content-Type": "text/plain", + }, + body: "planted", + }); + expect(res.status).toBe(403); + expect(existsSync(join(projectDir, "planted.html"))).toBe(false); + }); + + it("allows a write on a host named in the allowedHosts option", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-origin-guard-")); + tempDirs.push(projectDir); + const app = createStudioApi(stubAdapter(projectDir), { + allowedHosts: ["studio.example.com"], + }); + const res = await app.request("http://studio.example.com/projects/demo/files/planted.html", { + method: "POST", + headers: { + Host: "studio.example.com", + Origin: "https://studio.example.com", + "X-Forwarded-Proto": "https", + "Content-Type": "text/plain", + }, + body: "planted", + }); + expect(res.status).toBe(201); + expect(existsSync(join(projectDir, "planted.html"))).toBe(true); + }); + + it("rejects a write on that host without the allowedHosts option", async () => { + const { app, projectDir } = createApp(); + const res = await app.request("http://studio.example.com/projects/demo/files/planted.html", { + method: "POST", + headers: { + Host: "studio.example.com", + Origin: "https://studio.example.com", + "X-Forwarded-Proto": "https", + "Content-Type": "text/plain", + }, + body: "planted", + }); + expect(res.status).toBe(403); + expect(existsSync(join(projectDir, "planted.html"))).toBe(false); + }); + + it("allows a write through a tunnel that rewrites Host and forwards its own", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-origin-guard-")); + tempDirs.push(projectDir); + const app = createStudioApi(stubAdapter(projectDir), { + allowedHosts: ["demo.tunnels.example"], + }); + const res = await app.request("http://localhost/projects/demo/files/planted.html", { + method: "POST", + headers: { + Host: "localhost", + Origin: "https://demo.tunnels.example", + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "demo.tunnels.example", + "Content-Type": "text/plain", + }, + body: "planted", + }); + expect(res.status).toBe(201); + expect(existsSync(join(projectDir, "planted.html"))).toBe(true); + }); +}); diff --git a/packages/studio-server/src/createStudioApi.ts b/packages/studio-server/src/createStudioApi.ts index 3ca2db4d9ab..034d6849671 100644 --- a/packages/studio-server/src/createStudioApi.ts +++ b/packages/studio-server/src/createStudioApi.ts @@ -19,15 +19,52 @@ import { registerGlobalAssetRoutes } from "./routes/globalAssets.js"; import { registerHistoryRoutes } from "./routes/history.js"; import { replaceWithProjectDirMissing } from "./helpers/projectDirMissing.js"; import { folderGone, isProjectRootMissing } from "./helpers/safePath.js"; +import { sameOriginPost } from "./helpers/requestIdentity.js"; -/** - * Create a Hono sub-app with all studio API routes. - * - * Both the vite dev server and CLI embedded server mount this app - * under /api, each providing their own adapter for host-specific behavior. - */ -export function createStudioApi(adapter: StudioApiAdapter): Hono { +function urlHost(url: string): string | undefined { + try { + return new URL(url).host || undefined; + } catch { + return undefined; + } +} + +function urlScheme(url: string): string | undefined { + try { + return new URL(url).protocol.replace(/:$/, "") || undefined; + } catch { + return undefined; + } +} + +export interface StudioApiOptions { + /** + * Extra host names the write guard accepts as this server, for hosts + * mounting the API on their own domain or behind a tunnel or proxy. + */ + allowedHosts?: readonly string[]; +} + +export function createStudioApi(adapter: StudioApiAdapter, options: StudioApiOptions = {}): Hono { const api = new Hono(); + // This API is unauthenticated and simple cross-origin POSTs need no + // preflight, so the guard rejects writes the shared predicate refuses. + api.use(async function rejectCrossOriginWrites(c, next) { + if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") { + return next(); + } + const allowed = sameOriginPost({ + host: c.req.header("host") ?? urlHost(c.req.url), + origin: c.req.header("origin"), + fetchSite: c.req.header("sec-fetch-site"), + proto: urlScheme(c.req.url), + forwardedProto: c.req.header("x-forwarded-proto"), + forwardedHost: c.req.header("x-forwarded-host"), + allowedHosts: options.allowedHosts, + }); + if (!allowed) return c.json({ error: "forbidden" }, 403); + return next(); + }); api.use(async function answerProjectDirMissingAfterErrorHandlers(c, next) { const hostHeaders = new Headers(c.res.headers); await next(); diff --git a/packages/studio-server/src/helpers/requestIdentity.ts b/packages/studio-server/src/helpers/requestIdentity.ts new file mode 100644 index 00000000000..2bc921b0f6e --- /dev/null +++ b/packages/studio-server/src/helpers/requestIdentity.ts @@ -0,0 +1,100 @@ +// The one answer to "is this write same origin" for the CLI server and the studio API. The Host checks here are a DNS-rebinding mitigation for browsers, not access control: a browser cannot forge Host, a local client can set it freely. +import { hostname, networkInterfaces } from "node:os"; + +export function isLoopbackHost(host: string | undefined): boolean { + if (!host) return false; + 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); +} + +function hostnameOf(host: string | undefined): string { + if (!host) return ""; + const bracketed = /^\[([^\]]+)\]/.exec(host); + return (bracketed ? bracketed[1] : host.split(":")[0])?.toLowerCase() ?? ""; +} + +// Names this machine answers to on a wildcard bind: interface addresses, its own hostname, the .local mDNS form, and short forms of each. +function localNames(): Set { + const names = new Set(); + 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); + 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; +} + +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; +} + +// A loopback Host always passes. Any other Host passes only when the bind (HYPERFRAMES_PREVIEW_HOST) names it, or is a wildcard and the Host is a local name. +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); +} + +function hostAllowed(host: string, allowedHosts: readonly string[] | undefined): boolean { + if (identityAllowed(host)) return true; + if (!allowedHosts) return false; + const name = hostnameOf(host); + return name !== "" && allowedHosts.some((allowed) => hostnameOf(allowed) === name); +} + +function firstForwarded(value: string | undefined): string | undefined { + const first = value?.split(",")[0]?.trim(); + return first === "" ? undefined : first; +} + +function originMatches(origin: string, host: string, proto: string | undefined): boolean { + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + return false; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; + if (proto !== undefined && parsed.protocol !== `${proto}:`) return false; + return parsed.host.toLowerCase() === host.toLowerCase(); +} + +// proto is the scheme on the request URL the caller saw. The forwarded pair wins when a proxy supplies it: that is the origin the browser actually used. With no scheme known, an http or https origin passes. +export function sameOriginPost(headers: { + host?: string; + origin?: string; + fetchSite?: string; + proto?: string; + forwardedProto?: string; + forwardedHost?: string; + allowedHosts?: readonly string[]; +}): boolean { + const host = firstForwarded(headers.forwardedHost) ?? headers.host; + if (!host || !hostAllowed(host, headers.allowedHosts)) return false; + if (headers.fetchSite && headers.fetchSite !== "same-origin") return false; + if (headers.origin === undefined) return true; + const proto = firstForwarded(headers.forwardedProto) ?? headers.proto; + return originMatches(headers.origin, host, proto); +} diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts index a12867c942b..8b3aa95f62b 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -1,4 +1,4 @@ -export { createStudioApi } from "./createStudioApi.js"; +export { createStudioApi, type StudioApiOptions } from "./createStudioApi.js"; export { MAX_UPLOAD_BYTES } from "./routes/files.js"; export { createProjectSignature, diff --git a/packages/studio-server/tsup.config.ts b/packages/studio-server/tsup.config.ts index a5d80ea15c8..9e2e4581a53 100644 --- a/packages/studio-server/tsup.config.ts +++ b/packages/studio-server/tsup.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ "helpers/finiteMutation": "src/helpers/finiteMutation.ts", "helpers/sourceMutation": "src/helpers/sourceMutation.ts", "helpers/safePath": "src/helpers/safePath.ts", + "helpers/requestIdentity": "src/helpers/requestIdentity.ts", "helpers/hfIdPersist": "src/helpers/hfIdPersist.ts", "helpers/encoderOptions": "src/helpers/encoderOptions.ts", "history/index": "src/history/index.ts",