From 42574a7a4eaad35134e94e61d302d3df6a649698 Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Fri, 23 Oct 2026 12:28:21 +0000 Subject: [PATCH 1/3] fix(studio-server): reject cross-origin state-changing requests The studio API is unauthenticated and loopback binding is the only mitigation (F-001). CORS-safelisted POSTs (text/plain, form data) are sent without a preflight, so any website open in the user's browser could create and upload project files and trigger renders while hyperframes preview runs. Add a guard in createStudioApi: non-GET requests are refused with 403 when the Origin host does not match the request Host, when the Origin is unparseable (including the opaque literal null), or when Fetch Metadata marks the request cross-site. Requests with no Origin header (curl, tests, in-process callers) and all reads are unaffected. --- .../src/createStudioApi.originGuard.test.ts | 113 ++++++++++++++++++ packages/studio-server/src/createStudioApi.ts | 42 +++++++ 2 files changed, 155 insertions(+) create mode 100644 packages/studio-server/src/createStudioApi.originGuard.test.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..895b6f3d1e9 --- /dev/null +++ b/packages/studio-server/src/createStudioApi.originGuard.test.ts @@ -0,0 +1,113 @@ +// @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("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); + }); +}); diff --git a/packages/studio-server/src/createStudioApi.ts b/packages/studio-server/src/createStudioApi.ts index 3ca2db4d9ab..24b83b4b340 100644 --- a/packages/studio-server/src/createStudioApi.ts +++ b/packages/studio-server/src/createStudioApi.ts @@ -20,6 +20,24 @@ import { registerHistoryRoutes } from "./routes/history.js"; import { replaceWithProjectDirMissing } from "./helpers/projectDirMissing.js"; import { folderGone, isProjectRootMissing } from "./helpers/safePath.js"; +/** + * Whether a state-changing request's Origin names the host the request was + * sent to. No Origin header means a non-browser caller (curl, tests, + * in-process), which is allowed. An unparseable Origin (including the + * literal "null" a sandboxed page sends) names no host this server serves, + * so it never matches. + */ +function writeOriginAllowed(origin: string | undefined, host: string | undefined): boolean { + if (origin === undefined) return true; + let originHost: string | null = null; + try { + originHost = new URL(origin).host; + } catch { + originHost = null; + } + return !!originHost && !!host && originHost.toLowerCase() === host.toLowerCase(); +} + /** * Create a Hono sub-app with all studio API routes. * @@ -28,6 +46,30 @@ import { folderGone, isProjectRootMissing } from "./helpers/safePath.js"; */ export function createStudioApi(adapter: StudioApiAdapter): Hono { const api = new Hono(); + // The studio API is unauthenticated and its only legitimate caller is the + // Studio page this same server serves. A state-changing request that + // carries an Origin naming a different host than the request's Host did + // not come from that page: it is a cross-origin request from some other + // website open in the user's browser. CORS does not stop such requests + // from being SENT: POST with a text/plain or form body is "simple" and + // needs no preflight. It only stops the page reading the response, which + // a fire-and-forget file write or render trigger does not need. Reject + // those writes outright. Requests with no Origin header (curl, tests, + // in-process callers) are not browser traffic and keep working, as do + // reads: the guard covers state changes only. + api.use(async function rejectCrossOriginWrites(c, next) { + if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") { + return next(); + } + // Fetch Metadata: a cross-site request is refused even when the Origin + // header was stripped (some same-site-but-cross-origin setups send + // Sec-Fetch-Site: same-site, which stays allowed). + const crossSite = c.req.header("sec-fetch-site") === "cross-site"; + if (crossSite || !writeOriginAllowed(c.req.header("origin"), c.req.header("host"))) { + return c.json({ error: "forbidden" }, 403); + } + return next(); + }); api.use(async function answerProjectDirMissingAfterErrorHandlers(c, next) { const hostHeaders = new Headers(c.res.headers); await next(); From 110a300aef16b4c1f459b919c9a4f1855b30be75 Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Sat, 24 Oct 2026 03:27:08 +0000 Subject: [PATCH 2/3] fix(studio): guard API writes with the shared sameOriginPost predicate Replace the guard's own Origin-host-equals-Host comparison with the CLI server's sameOriginPost, lifted into studio-server as helpers/requestIdentity (new ./request-identity subpath) together with isLoopbackHost and identityAllowed. The CLI imports the same definition from there; desktopRoutes and telemetryIdentity re-export so their callers and tests do not move. The old comparison let a page whose DNS name resolves to 127.0.0.1 through, because its Origin and Host agree with each other. sameOriginPost also requires the Host to be an identity the server answers to, and refuses any Sec-Fetch-Site other than same-origin. Regression tests cover the rebinding pair, a no-Origin write on such a host, and the same-site case. --- packages/cli/src/server/desktopRoutes.ts | 18 +-- packages/cli/src/server/telemetryIdentity.ts | 133 ++--------------- packages/studio-server/package-subpaths.json | 6 + packages/studio-server/package.json | 10 ++ .../src/createStudioApi.originGuard.test.ts | 34 +++++ packages/studio-server/src/createStudioApi.ts | 54 +++---- .../src/helpers/requestIdentity.ts | 137 ++++++++++++++++++ packages/studio-server/tsup.config.ts | 1 + 8 files changed, 236 insertions(+), 157 deletions(-) create mode 100644 packages/studio-server/src/helpers/requestIdentity.ts diff --git a/packages/cli/src/server/desktopRoutes.ts b/packages/cli/src/server/desktopRoutes.ts index 54fc22023ec..fa06450a842 100644 --- a/packages/cli/src/server/desktopRoutes.ts +++ b/packages/cli/src/server/desktopRoutes.ts @@ -5,21 +5,13 @@ 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}$/; +// Re-exported for this module's tests; the definition is shared with +// the studio API's write guard. +export { sameOriginPost }; -/** 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}`; -} +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( 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 index 895b6f3d1e9..b163a794cfb 100644 --- a/packages/studio-server/src/createStudioApi.originGuard.test.ts +++ b/packages/studio-server/src/createStudioApi.originGuard.test.ts @@ -85,6 +85,40 @@ describe("createStudioApi cross-origin write guard", () => { 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, { diff --git a/packages/studio-server/src/createStudioApi.ts b/packages/studio-server/src/createStudioApi.ts index 24b83b4b340..de7b51e9d38 100644 --- a/packages/studio-server/src/createStudioApi.ts +++ b/packages/studio-server/src/createStudioApi.ts @@ -19,23 +19,15 @@ 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"; -/** - * Whether a state-changing request's Origin names the host the request was - * sent to. No Origin header means a non-browser caller (curl, tests, - * in-process), which is allowed. An unparseable Origin (including the - * literal "null" a sandboxed page sends) names no host this server serves, - * so it never matches. - */ -function writeOriginAllowed(origin: string | undefined, host: string | undefined): boolean { - if (origin === undefined) return true; - let originHost: string | null = null; +/** Host named by a request URL, or undefined when it does not parse. */ +function urlHost(url: string): string | undefined { try { - originHost = new URL(origin).host; + return new URL(url).host || undefined; } catch { - originHost = null; + return undefined; } - return !!originHost && !!host && originHost.toLowerCase() === host.toLowerCase(); } /** @@ -47,27 +39,37 @@ function writeOriginAllowed(origin: string | undefined, host: string | undefined export function createStudioApi(adapter: StudioApiAdapter): Hono { const api = new Hono(); // The studio API is unauthenticated and its only legitimate caller is the - // Studio page this same server serves. A state-changing request that - // carries an Origin naming a different host than the request's Host did - // not come from that page: it is a cross-origin request from some other + // Studio page this same server serves. A state-changing request that did + // not come from that page is a cross-origin request from some other // website open in the user's browser. CORS does not stop such requests // from being SENT: POST with a text/plain or form body is "simple" and // needs no preflight. It only stops the page reading the response, which // a fire-and-forget file write or render trigger does not need. Reject - // those writes outright. Requests with no Origin header (curl, tests, - // in-process callers) are not browser traffic and keep working, as do - // reads: the guard covers state changes only. + // those writes outright. Reads are unaffected: the guard covers state + // changes only. + // + // The predicate is the CLI server's own `sameOriginPost`, shared from + // helpers/requestIdentity so both servers give one answer. Comparing the + // Origin host to the Host header alone is not enough: a page whose own + // DNS name resolves to 127.0.0.1 sends a matching pair and walks through. + // `sameOriginPost` also requires the Host to be an identity this server + // answers to (a loopback literal, or the LAN name it was bound with) and + // refuses any Sec-Fetch-Site other than "same-origin". Callers with no + // Origin header (curl, tests, in-process) still pass on a loopback Host, + // which is the only Host a loopback-bound server can be reached on. When + // the Host header is absent the request never came over the wire + // (in-process callers address the app by URL, and a real server builds + // that URL from the Host header), so the URL's host is the same value. api.use(async function rejectCrossOriginWrites(c, next) { if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") { return next(); } - // Fetch Metadata: a cross-site request is refused even when the Origin - // header was stripped (some same-site-but-cross-origin setups send - // Sec-Fetch-Site: same-site, which stays allowed). - const crossSite = c.req.header("sec-fetch-site") === "cross-site"; - if (crossSite || !writeOriginAllowed(c.req.header("origin"), c.req.header("host"))) { - return c.json({ error: "forbidden" }, 403); - } + const allowed = sameOriginPost({ + host: c.req.header("host") ?? urlHost(c.req.url), + origin: c.req.header("origin"), + fetchSite: c.req.header("sec-fetch-site"), + }); + if (!allowed) return c.json({ error: "forbidden" }, 403); return next(); }); api.use(async function answerProjectDirMissingAfterErrorHandlers(c, 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..664be921375 --- /dev/null +++ b/packages/studio-server/src/helpers/requestIdentity.ts @@ -0,0 +1,137 @@ +/** + * The one answer to "is this write same origin", shared by the CLI server + * and the studio API. + * + * Lifted out of the CLI server so both import this one definition: the CLI's + * open-in-desktop route and the studio API's write guard ask the same + * question, and two copies of the check would drift into two different + * answers. The split also keeps the predicate unit-testable without pulling + * in either server's heavier dependencies. + */ +import { hostname, networkInterfaces } from "node:os"; + +/** + * 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); +} + +/** 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 a request carrying this `Host` be treated as addressed to this server? + * + * 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); +} + +/** + * A bodiless POST is a simple request, so any page can send one to localhost: + * it has to come from this Studio. Both write guards apply this one function, + * the CLI server's open-in-desktop route and the studio API's write + * middleware, so the two servers cannot drift into different answers. + */ +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}`; +} 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", From 89011e1c0132e162f9ad6cddb023672f157b2bff Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Sat, 24 Oct 2026 18:15:03 +0200 Subject: [PATCH 3/3] fix(studio): scheme-aware origin check, allowedHosts option sameOriginPost compares the Origin against the request's own origin in a scheme-aware way: the scheme comes from the request URL, or from x-forwarded-proto when a TLS proxy supplies it, and x-forwarded-host is honored for tunnels that rewrite Host. An https origin on the same host now passes, where every write behind a TLS proxy 403d before. createStudioApi gains an allowedHosts option naming extra hosts the guard accepts, for hosts mounting the API on their own domain. Default is unchanged: loopback, or the HYPERFRAMES_PREVIEW_HOST bind. desktopRoutes drops its sameOriginPost re-export; its test imports the shared subpath directly. Comments trimmed so the comment ratchet passes. Docs note in docs/packages/studio-server.mdx. Tests: origin guard suite 17/17 (6 new: https same-origin write, forwarded proto, https origin on a plain http request, allowedHosts accept and reject, tunnel with rewritten Host). Full studio-server suite 1360 passed, 1 failed (history file-mode, fails on the base commit too in this environment). CLI server suites 226 passed. Typecheck, oxlint, oxfmt clean. --- docs/packages/studio-server.mdx | 14 +++ packages/cli/src/server/desktopRoutes.test.ts | 3 +- packages/cli/src/server/desktopRoutes.ts | 14 ++- .../src/createStudioApi.originGuard.test.ts | 103 ++++++++++++++++ packages/studio-server/src/createStudioApi.ts | 53 ++++---- .../src/helpers/requestIdentity.ts | 115 ++++++------------ packages/studio-server/src/index.ts | 2 +- 7 files changed, 193 insertions(+), 111 deletions(-) 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 fa06450a842..ad55f92a806 100644 --- a/packages/cli/src/server/desktopRoutes.ts +++ b/packages/cli/src/server/desktopRoutes.ts @@ -7,9 +7,14 @@ import { } from "../utils/desktopApp.js"; import { sameOriginPost } from "@hyperframes/studio-server/request-identity"; -// Re-exported for this module's tests; the definition is shared with -// the studio API's write guard. -export { sameOriginPost }; +/** 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}$/; @@ -40,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/studio-server/src/createStudioApi.originGuard.test.ts b/packages/studio-server/src/createStudioApi.originGuard.test.ts index b163a794cfb..5601fa4e58e 100644 --- a/packages/studio-server/src/createStudioApi.originGuard.test.ts +++ b/packages/studio-server/src/createStudioApi.originGuard.test.ts @@ -144,4 +144,107 @@ describe("createStudioApi cross-origin write guard", () => { }); 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 de7b51e9d38..034d6849671 100644 --- a/packages/studio-server/src/createStudioApi.ts +++ b/packages/studio-server/src/createStudioApi.ts @@ -21,7 +21,6 @@ import { replaceWithProjectDirMissing } from "./helpers/projectDirMissing.js"; import { folderGone, isProjectRootMissing } from "./helpers/safePath.js"; import { sameOriginPost } from "./helpers/requestIdentity.js"; -/** Host named by a request URL, or undefined when it does not parse. */ function urlHost(url: string): string | undefined { try { return new URL(url).host || undefined; @@ -30,36 +29,26 @@ function urlHost(url: string): string | undefined { } } -/** - * 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 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(); - // The studio API is unauthenticated and its only legitimate caller is the - // Studio page this same server serves. A state-changing request that did - // not come from that page is a cross-origin request from some other - // website open in the user's browser. CORS does not stop such requests - // from being SENT: POST with a text/plain or form body is "simple" and - // needs no preflight. It only stops the page reading the response, which - // a fire-and-forget file write or render trigger does not need. Reject - // those writes outright. Reads are unaffected: the guard covers state - // changes only. - // - // The predicate is the CLI server's own `sameOriginPost`, shared from - // helpers/requestIdentity so both servers give one answer. Comparing the - // Origin host to the Host header alone is not enough: a page whose own - // DNS name resolves to 127.0.0.1 sends a matching pair and walks through. - // `sameOriginPost` also requires the Host to be an identity this server - // answers to (a loopback literal, or the LAN name it was bound with) and - // refuses any Sec-Fetch-Site other than "same-origin". Callers with no - // Origin header (curl, tests, in-process) still pass on a loopback Host, - // which is the only Host a loopback-bound server can be reached on. When - // the Host header is absent the request never came over the wire - // (in-process callers address the app by URL, and a real server builds - // that URL from the Host header), so the URL's host is the same value. + // 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(); @@ -68,6 +57,10 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono { 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(); diff --git a/packages/studio-server/src/helpers/requestIdentity.ts b/packages/studio-server/src/helpers/requestIdentity.ts index 664be921375..2bc921b0f6e 100644 --- a/packages/studio-server/src/helpers/requestIdentity.ts +++ b/packages/studio-server/src/helpers/requestIdentity.ts @@ -1,54 +1,22 @@ -/** - * The one answer to "is this write same origin", shared by the CLI server - * and the studio API. - * - * Lifted out of the CLI server so both import this one definition: the CLI's - * open-in-desktop route and the studio API's write guard ask the same - * question, and two copies of the check would drift into two different - * answers. The split also keeps the predicate unit-testable without pulling - * in either server's heavier dependencies. - */ +// 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"; -/** - * 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. + // 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); } -/** 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. - */ +// 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 { @@ -58,8 +26,6 @@ function localNames(): Set { 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 !== "") { @@ -73,13 +39,6 @@ function localNames(): Set { 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; @@ -90,28 +49,7 @@ function hostMatchesBind(host: string | undefined, bind: string): boolean { : false; } -/** - * May a request carrying this `Host` be treated as addressed to this server? - * - * 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. - */ +// 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(); @@ -119,19 +57,44 @@ export function identityAllowed(host: string | undefined): boolean { return hostMatchesBind(host, bind); } -/** - * A bodiless POST is a simple request, so any page can send one to localhost: - * it has to come from this Studio. Both write guards apply this one function, - * the CLI server's open-in-desktop route and the studio API's write - * middleware, so the two servers cannot drift into different answers. - */ +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, origin, fetchSite } = headers; - if (!host || !identityAllowed(host)) return false; - if (fetchSite && fetchSite !== "same-origin") return false; - return origin === undefined || origin === `http://${host}`; + 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,