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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/cli/src/server/studioServer.pathSafety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import { tmpdir } from "node:os";
import { createStudioServer } from "./studioServer.js";

/**
* The static SPA handlers resolve the raw request path against the bundle
* directory. Hono decodes percent escapes when routing, so an encoded
* separator or dot segment in the request must never walk out of the bundle
* root — browsers would not send those shapes, but anything that can reach
* the bound port can. These tests pin containment at the handler.
*/

const hooks = vi.hoisted(() => ({ studioDir: "" }));

// The bundle directory is resolved from __dirname at server construction, so
// point that one `resolve(<...>/server, "studio")` call at a temp tree.
vi.mock("node:path", async (importOriginal) => {
const actual = await importOriginal<typeof path>();
return {
...actual,
resolve: (...parts: string[]) =>
hooks.studioDir && parts.length === 2 && parts[0]?.endsWith("server") && parts[1] === "studio"
? hooks.studioDir
: actual.resolve(...parts),
};
});

let root: string;
let server: Awaited<ReturnType<typeof createStudioServer>>;

beforeEach(async () => {
root = fs.mkdtempSync(path.join(tmpdir(), "hf-studio-containment-"));
hooks.studioDir = path.join(root, "studio");
const projectDir = path.join(root, "project");
fs.mkdirSync(projectDir);
fs.mkdirSync(path.join(hooks.studioDir, "assets"), { recursive: true });
fs.writeFileSync(path.join(root, "studio-marker.txt"), "STUDIO-MARKER");
fs.writeFileSync(
path.join(hooks.studioDir, "index.html"),
"<html><head></head><body>Studio</body></html>",
);
server = await createStudioServer({ projectDir });
});

afterEach(() => {
server.watcher.close();
fs.rmSync(root, { recursive: true, force: true });
hooks.studioDir = "";
});

describe("static file path containment", () => {
// A percent-encoded backslash decodes to a real separator on Windows, so
// two encoded hops walk from the bundle root to the temp root above it.
it.each(["/assets/..%5c..%5cstudio-marker.txt", "/icons/..%5c..%5cstudio-marker.txt"])(
"rejects encoded traversal %s",
async (requestPath) => {
const response = await server.app.request(requestPath);
expect(response.status).toBe(404);
expect(await response.text()).not.toContain("STUDIO-MARKER");
},
);

it("still serves the SPA shell and bundle assets", async () => {
const shell = await server.app.request("/");
expect(shell.status).toBe(200);
expect(await shell.text()).toContain("Studio");

fs.writeFileSync(path.join(hooks.studioDir, "assets", "app.js"), "export {};");
const asset = await server.app.request("/assets/app.js");
expect(asset.status).toBe(200);
expect(await asset.text()).toContain("export {};");
});
});
7 changes: 7 additions & 0 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { resolveAutoProxy } from "../utils/projectConfig.js";
import { getElementScreenshotClip } from "@hyperframes/studio-server/screenshot-clip";
import type { ScreenshotClip } from "@hyperframes/studio-server/screenshot-clip";
import type { RenderJob } from "@hyperframes/producer";
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
import { seekCompositionTimeline } from "../capture/captureCompositionFrame.js";
import {
assertWebGpuAdapterAvailable,
Expand Down Expand Up @@ -881,6 +882,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// Studio SPA static files
const serveStudioStaticFile = (cacheControl: string) => (c: Context) => {
const filePath = resolve(studioDir, c.req.path.slice(1));
// Percent escapes can decode into separators and dot segments before this
// resolve, so a hostile request can name files above the bundle
// directory. Containment stays lexical on purpose: bundle assets may sit
// behind symlinked directories (same tradeoff as the preview asset
// route), but dot segments must never collapse outside the bundle root.
if (!isWithinProjectRoot(studioDir, filePath)) return c.text("not found", 404);
const content = readBundleFile(filePath);
if (content === null) return c.text("not found", 404);
return new Response(content, {
Expand Down
63 changes: 63 additions & 0 deletions packages/engine/src/services/fileServer.pathSafety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import { tmpdir } from "node:os";
import { createFileServer, type FileServerHandle } from "./fileServer.js";

/**
* The server derives each request path from the URL and joins it onto
* compiledDir/projectDir. Hono decodes percent escapes when routing, so an
* encoded separator or dot segment in the request must never walk out of the
* served roots — Chrome never requests those shapes, but anything that can
* reach the port (LAN bind, other local processes) can. On Windows an encoded
* backslash (%5c) decodes to a real separator, so `..%5c..%5c` escapes even
* though `..%2f` is normalized away by join(). These tests pin containment
* at the handler.
*/
describe("fileServer path containment", () => {
let root: string;
let projectDir: string;
let outsideFile: string;
let server: FileServerHandle;

beforeEach(async () => {
root = fs.mkdtempSync(path.join(tmpdir(), "hf-fileserver-containment-"));
projectDir = path.join(root, "project");
fs.mkdirSync(projectDir);
fs.writeFileSync(path.join(projectDir, "index.html"), "<html></html>");
outsideFile = path.join(root, "outside-secret.txt");
fs.writeFileSync(outsideFile, "OUTSIDE");
server = await createFileServer({
headScripts: [],
bodyScripts: [],
projectDir,
});
});

afterEach(async () => {
await server.close();
fs.rmSync(root, { recursive: true, force: true });
});

const escapeAttempts = [
// Encoded separators: %2f collapses with a leading dot-segment only via a
// nested hop, %5c is a live separator on Windows.
"/..%2foutside-secret.txt",
"/%2e%2e%2foutside-secret.txt",
"/nested%2f..%2f..%2foutside-secret.txt",
"/..%5coutside-secret.txt",
"/..%5c..%5coutside-secret.txt",
];

it.each(escapeAttempts)("rejects encoded traversal %s", async (requestPath) => {
const response = await fetch(`${server.url}${requestPath}`);
expect(response.status).toBe(404);
expect(await response.text()).not.toContain("OUTSIDE");
});

it("still serves files inside the project", async () => {
const response = await fetch(`${server.url}/index.html`);
expect(response.status).toBe(200);
expect(await response.text()).toContain("<html>");
});
});
19 changes: 16 additions & 3 deletions packages/engine/src/services/fileServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { serve } from "@hono/node-server";
import { readFileSync, openSync, fstatSync, closeSync, statSync, constants } from "node:fs";
import { join, extname } from "node:path";
import { injectScriptsIntoHtml } from "@hyperframes/core/compiler";
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";

const MIME_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
Expand Down Expand Up @@ -98,10 +99,22 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer

// Remove leading slash
const relativePath = requestPath.replace(/^\//, "");
const compiledPath = compiledDir ? join(compiledDir, relativePath) : null;
const compiledDirRoot = compiledDir;
const compiledPath = compiledDirRoot ? join(compiledDirRoot, relativePath) : null;
const projectPath = join(projectDir, relativePath);
// Percent escapes can decode into separators and dot segments before the
// join, so a hostile request can name files above both roots. Containment
// stays lexical on purpose: project assets are allowed to sit behind
// symlinked directories (same tradeoff as the preview asset route), but
// dot segments must never collapse outside the roots.
if (compiledDirRoot && compiledPath && !isWithinProjectRoot(compiledDirRoot, compiledPath)) {
return c.text("Not found", 404);
}
if (!isWithinProjectRoot(projectDir, projectPath)) {
return c.text("Not found", 404);
}
const content =
(compiledPath ? readRegularFile(compiledPath) : null) ??
readRegularFile(join(projectDir, relativePath));
(compiledPath ? readRegularFile(compiledPath) : null) ?? readRegularFile(projectPath);
if (content === null) return c.text("Not found", 404);

const ext = extname(relativePath).toLowerCase();
Expand Down