Skip to content

Commit 27193bb

Browse files
Dextheking1Dextheking1
authored andcommitted
feat(studio-server): read extra write hosts from HYPERFRAMES_PREVIEW_ALLOWED_HOSTS
The CLI preview server and the Studio dev server now read the comma separated HYPERFRAMES_PREVIEW_ALLOWED_HOSTS and pass the names to createStudioApi as allowedHosts, so a preview behind Codespaces port forwarding or a tunnel accepts writes from its public host. The parsing lives in requestIdentity, next to HYPERFRAMES_PREVIEW_HOST. A refused write now names the refused host and the variable that allows it, instead of a bare forbidden. Tests: guard suite covers the refusal message and a forged X-Forwarded-Host with a non-matching Origin; the CLI suite drives a tunnel write through the /api/* forwarder with and without the variable set.
1 parent 8b9841c commit 27193bb

8 files changed

Lines changed: 172 additions & 5 deletions

File tree

‎docs/packages/studio-server.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ app.route(
6161
);
6262
```
6363

64+
With `hyperframes preview` and the Studio dev server there is no code to
65+
change: both read the extra hosts from `HYPERFRAMES_PREVIEW_ALLOWED_HOSTS`
66+
(comma separated) and pass them as `allowedHosts`. A refused write answers
67+
403 with the refused host named and this variable as the fix:
68+
69+
```bash
70+
HYPERFRAMES_PREVIEW_ALLOWED_HOSTS=demo.tunnels.example hyperframes preview
71+
```
72+
6473
A `StudioApiAdapter` supplies:
6574

6675
- project listing and resolution;

‎packages/cli/src/server/studioServer.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,3 +1027,53 @@ describe("Studio file-change SSE", () => {
10271027
expect(payload).toContain('"projectId":"demo-project"');
10281028
});
10291029
});
1030+
1031+
describe("createStudioServer /api write guard allowed hosts", () => {
1032+
const TUNNEL_HOST = "demo.tunnels.example";
1033+
1034+
async function tunnelWrite(projectDir: string): Promise<Response> {
1035+
// A write from the Studio page behind a tunnel, driven through the
1036+
// CLI's own /api/* forwarder, the way a browser save reaches the API.
1037+
return server!.app.request(
1038+
`http://${TUNNEL_HOST}/api/projects/${encodeURIComponent(basename(projectDir))}/files/planted.html`,
1039+
{
1040+
method: "POST",
1041+
headers: {
1042+
Host: TUNNEL_HOST,
1043+
Origin: `https://${TUNNEL_HOST}`,
1044+
"X-Forwarded-Proto": "https",
1045+
"Content-Type": "text/plain",
1046+
},
1047+
body: "<html>planted</html>",
1048+
},
1049+
);
1050+
}
1051+
1052+
it("refuses a tunnel write without the env var, naming the host and the var", async () => {
1053+
const projectDir = tmpProject();
1054+
writeFileSync(join(projectDir, "index.html"), "<html>before</html>");
1055+
delete process.env.HYPERFRAMES_PREVIEW_ALLOWED_HOSTS;
1056+
server = createStudioServer({ projectDir });
1057+
1058+
const res = await tunnelWrite(projectDir);
1059+
expect(res.status).toBe(403);
1060+
const body = (await res.json()) as { error: string };
1061+
expect(body.error).toContain(TUNNEL_HOST);
1062+
expect(body.error).toContain("HYPERFRAMES_PREVIEW_ALLOWED_HOSTS");
1063+
expect(existsSync(join(projectDir, "planted.html"))).toBe(false);
1064+
});
1065+
1066+
it("accepts a tunnel write when HYPERFRAMES_PREVIEW_ALLOWED_HOSTS names the host", async () => {
1067+
const projectDir = tmpProject();
1068+
writeFileSync(join(projectDir, "index.html"), "<html>before</html>");
1069+
process.env.HYPERFRAMES_PREVIEW_ALLOWED_HOSTS = `other.example, ${TUNNEL_HOST}`;
1070+
try {
1071+
server = createStudioServer({ projectDir });
1072+
const res = await tunnelWrite(projectDir);
1073+
expect(res.status).toBe(201);
1074+
expect(existsSync(join(projectDir, "planted.html"))).toBe(true);
1075+
} finally {
1076+
delete process.env.HYPERFRAMES_PREVIEW_ALLOWED_HOSTS;
1077+
}
1078+
});
1079+
});

‎packages/cli/src/server/studioServer.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js";
3737
import { startRenderCpuSampler } from "../telemetry/renderCpu.js";
3838
import { mountDesktopRoutes } from "./desktopRoutes.js";
39+
import { previewAllowedHostsFromEnv } from "@hyperframes/studio-server/request-identity";
3940
import { isDevMode } from "../utils/env.js";
4041
import { runRenderSetupWorker } from "../utils/cancellableProcess.js";
4142
import type { ProjectLintResult } from "@hyperframes/lint";
@@ -1102,7 +1103,9 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
11021103
// Mount the shared studio API at /api.
11031104
// Use fetch() forwarding (not .route()) so the sub-app sees paths without
11041105
// the /api prefix — the shared module's path extraction uses c.req.path.
1105-
const api = createStudioApi(adapter);
1106+
// Extra write hosts from HYPERFRAMES_PREVIEW_ALLOWED_HOSTS, for preview
1107+
// behind Codespaces port forwarding or a tunnel (see requestIdentity).
1108+
const api = createStudioApi(adapter, { allowedHosts: previewAllowedHostsFromEnv() });
11061109
app.all("/api/*", async (c) => {
11071110
const url = new URL(c.req.url);
11081111
url.pathname = url.pathname.slice(4); // Strip "/api" prefix

‎packages/studio-server/src/createStudioApi.originGuard.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs";
99
import { tmpdir } from "node:os";
1010
import { join } from "node:path";
1111
import { createStudioApi } from "./createStudioApi";
12+
import { previewAllowedHostsFromEnv } from "./helpers/requestIdentity";
1213
import { stubAdapter } from "./routes/stubAdapter.test-helpers";
1314

1415
const tempDirs: string[] = [];
@@ -227,6 +228,36 @@ describe("createStudioApi cross-origin write guard", () => {
227228
expect(existsSync(join(projectDir, "planted.html"))).toBe(false);
228229
});
229230

231+
it("names the refused host and the allowing env var in a refused write", async () => {
232+
const { app, projectDir } = createApp();
233+
const res = await createFile(app, {
234+
Host: "rebind.example:9999",
235+
Origin: "http://rebind.example:9999",
236+
"Sec-Fetch-Site": "same-origin",
237+
"Content-Type": "text/plain",
238+
});
239+
expect(res.status).toBe(403);
240+
const body = (await res.json()) as { error: string };
241+
expect(body.error).toContain("rebind.example:9999");
242+
expect(body.error).toContain("HYPERFRAMES_PREVIEW_ALLOWED_HOSTS");
243+
expect(existsSync(join(projectDir, "planted.html"))).toBe(false);
244+
});
245+
246+
it("rejects a forged X-Forwarded-Host whose Origin does not match it", async () => {
247+
// The Host check alone would pass: the forwarded host names loopback.
248+
// The Origin comparison is what stops it, since the page's real origin
249+
// is the rebinding name, not the forwarded one.
250+
const { app, projectDir } = createApp();
251+
const res = await createFile(app, {
252+
Host: "rebind.example:9999",
253+
Origin: "http://rebind.example:9999",
254+
"X-Forwarded-Host": "localhost",
255+
"Content-Type": "text/plain",
256+
});
257+
expect(res.status).toBe(403);
258+
expect(existsSync(join(projectDir, "planted.html"))).toBe(false);
259+
});
260+
230261
it("allows a write through a tunnel that rewrites Host and forwards its own", async () => {
231262
const projectDir = mkdtempSync(join(tmpdir(), "hf-origin-guard-"));
232263
tempDirs.push(projectDir);
@@ -248,3 +279,18 @@ describe("createStudioApi cross-origin write guard", () => {
248279
expect(existsSync(join(projectDir, "planted.html"))).toBe(true);
249280
});
250281
});
282+
283+
describe("previewAllowedHostsFromEnv", () => {
284+
it("parses a comma separated list, trimming blanks", () => {
285+
expect(
286+
previewAllowedHostsFromEnv({
287+
HYPERFRAMES_PREVIEW_ALLOWED_HOSTS: " demo.tunnels.example ,, studio.example.com ",
288+
}),
289+
).toEqual(["demo.tunnels.example", "studio.example.com"]);
290+
});
291+
292+
it("is empty when the variable is unset or blank", () => {
293+
expect(previewAllowedHostsFromEnv({})).toEqual([]);
294+
expect(previewAllowedHostsFromEnv({ HYPERFRAMES_PREVIEW_ALLOWED_HOSTS: " " })).toEqual([]);
295+
});
296+
});

‎packages/studio-server/src/createStudioApi.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ import { registerGlobalAssetRoutes } from "./routes/globalAssets.js";
1919
import { registerHistoryRoutes } from "./routes/history.js";
2020
import { replaceWithProjectDirMissing } from "./helpers/projectDirMissing.js";
2121
import { folderGone, isProjectRootMissing } from "./helpers/safePath.js";
22-
import { sameOriginPost } from "./helpers/requestIdentity.js";
22+
import {
23+
PREVIEW_ALLOWED_HOSTS_ENV,
24+
requestHost,
25+
sameOriginPost,
26+
} from "./helpers/requestIdentity.js";
2327
import { previewErrorPage } from "./helpers/previewErrorPage.js";
2428

2529
async function answerRefusedFrameWithPage(c: Context, next: Next) {
@@ -75,7 +79,25 @@ export function createStudioApi(adapter: StudioApiAdapter, options: StudioApiOpt
7579
forwardedHost: c.req.header("x-forwarded-host"),
7680
allowedHosts: options.allowedHosts,
7781
});
78-
if (!allowed) return c.json({ error: "forbidden" }, 403);
82+
if (!allowed) {
83+
// Name the refused host and the variable that allows it, so a user
84+
// behind a proxy or tunnel can fix the 403 in one step instead of
85+
// silently losing every save.
86+
const refusedHost =
87+
requestHost({
88+
host: c.req.header("host") ?? urlHost(c.req.url),
89+
forwardedHost: c.req.header("x-forwarded-host"),
90+
}) ?? "unknown host";
91+
return c.json(
92+
{
93+
error:
94+
`forbidden: writes from host "${refusedHost}" are not allowed. ` +
95+
`Set ${PREVIEW_ALLOWED_HOSTS_ENV}=${refusedHost} to allow it ` +
96+
`(comma separated for several hosts).`,
97+
},
98+
403,
99+
);
100+
}
79101
return next();
80102
});
81103
api.use("/projects/:id/preview/*", answerRefusedFrameWithPage);

‎packages/studio-server/src/helpers/requestIdentity.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,30 @@ function hostMatchesBind(host: string | undefined, bind: string): boolean {
4949
: false;
5050
}
5151

52+
// Extra hosts whose writes the studio API accepts, comma separated. The CLI
53+
// preview server and the Studio dev server read it and pass the names to
54+
// createStudioApi as allowedHosts, so a Codespaces or tunnel user allows
55+
// their public host name in one step. A refused write names this variable.
56+
export const PREVIEW_ALLOWED_HOSTS_ENV = "HYPERFRAMES_PREVIEW_ALLOWED_HOSTS";
57+
58+
export function previewAllowedHostsFromEnv(
59+
env: { [key: string]: string | undefined } = process.env,
60+
): string[] {
61+
return (env[PREVIEW_ALLOWED_HOSTS_ENV] ?? "")
62+
.split(",")
63+
.map((host) => host.trim())
64+
.filter((host) => host !== "");
65+
}
66+
67+
// The host a request is addressed to, for checks and for refusal messages:
68+
// the first forwarded host wins, since that is the name the browser used.
69+
export function requestHost(headers: {
70+
host?: string;
71+
forwardedHost?: string;
72+
}): string | undefined {
73+
return firstForwarded(headers.forwardedHost) ?? headers.host;
74+
}
75+
5276
// 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.
5377
export function identityAllowed(host: string | undefined): boolean {
5478
if (isLoopbackHost(host)) return true;

‎packages/studio-server/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
export { createStudioApi, type StudioApiOptions } from "./createStudioApi.js";
2+
export {
3+
PREVIEW_ALLOWED_HOSTS_ENV,
4+
previewAllowedHostsFromEnv,
5+
} from "./helpers/requestIdentity.js";
26
export { MAX_UPLOAD_BYTES } from "./routes/files.js";
37
export {
48
createProjectSignature,

‎packages/studio/vite.config.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,13 @@ function devProjectApi(): Plugin {
143143

144144
let _api: { fetch: (req: Request) => Promise<Response> } | null = null;
145145
let _studioServerModule: {
146-
createStudioApi: (adapter: ReturnType<typeof createViteAdapter>) => {
146+
createStudioApi: (
147+
adapter: ReturnType<typeof createViteAdapter>,
148+
options?: { allowedHosts?: readonly string[] },
149+
) => {
147150
fetch: (req: Request) => Promise<Response>;
148151
};
152+
previewAllowedHostsFromEnv: () => string[];
149153
identifyFileWrite: (
150154
path: string,
151155
expectedVersion: string,
@@ -170,6 +174,7 @@ function devProjectApi(): Plugin {
170174
"fileContentVersion",
171175
"affectsPreview",
172176
"openProjectHistory",
177+
"previewAllowedHostsFromEnv",
173178
] as const) {
174179
if (typeof mod[name] !== "function") {
175180
throw new Error(`@hyperframes/studio-server dev module is missing ${name}()`);
@@ -183,7 +188,11 @@ function devProjectApi(): Plugin {
183188
// id and real root before the signature cache starts watching them.
184189
onResolveProject: (project) => watchedProjects.set(project.dir, project.id),
185190
});
186-
_api = mod.createStudioApi(adapter);
191+
// Same env var as the CLI preview server: extra hosts whose
192+
// writes the API accepts (Codespaces, tunnels).
193+
_api = mod.createStudioApi(adapter, {
194+
allowedHosts: mod.previewAllowedHostsFromEnv(),
195+
});
187196
}
188197
return _api;
189198
};

0 commit comments

Comments
 (0)