Files
hyperframes/packages/cli/src/server/studioServer.staticAssets.test.ts
T
cc19b8c812 fix(server): contain static file requests within their served roots (#4217)
* fix(server): contain static file requests within their served roots

Static asset handlers joined the raw request path onto their root
directory without checking where it landed. Hono decodes percent
escapes while routing, so an encoded backslash (%5c) arrives as a real
separator on Windows and dot segments arrive already collapsed —
"/assets/..%5c..%5csecret" and "/%2e%2e%2fsecret" could read files
above the studio bundle root (embedded Studio server) and above
compiledDir/projectDir (engine render file server). Browsers never
request those shapes, but anything that can reach the bound port can:
raw local clients, LAN peers when the preview binds 0.0.0.0, and a
shared-machine process hitting the render server.

Both handlers now resolve the request path first and refuse anything
that collapses outside its root, using the same lexical containment
the preview asset route already applies — bundle/project assets behind
symlinked directories keep being served, only dot-segment escapes are
rejected. Route-level pathSafety tests pin the behavior in both
packages, including the Windows backslash shape and the
symlinked-asset cases from the descriptor-pinning regressions.

Validated: new containment suites fail on the prior handlers (200 +
marker file content served from outside the root) and pass with the
guards; cli server (140), engine services (1024+, matching the main
baseline incl. pre-existing browserManager failures), typecheck,
oxlint, oxfmt and the full workspace build all clean.

* fix: close fallow audit findings, prove containment cross-platform

Fallow audit (health/complexity): createFileServer's request handler
exceeded the CRAP-score threshold once the new containment checks were
added. Extracted resolveContainedPaths, a small pure function that
resolves and validates both roots, cutting the handler's own branching
back down.

Fallow audit (duplication): the new pathSafety test files copied their
temp-root/studioDir setup and teardown from the existing staticAssets
test wholesale. Extracted the shared parts into
studioServerTestFixture.ts (makeStudioServerRoot, writeStudioIndexHtml,
cleanupStudioServerRoot); only the per-file vi.mock("node:path", ...)
call stays duplicated, since Vitest requires it declared statically in
each test file and it cannot be moved behind a shared function -- that
one block keeps a narrow, targeted ignore comment instead of a
file-wide one.

Security claim: isWithinProjectRoot's %5c-decoded-backslash escape is
real, but only on Windows -- POSIX never treats a literal backslash as
a separator, and Hono leaves %2f undecoded in c.req.path, so none of
the new tests' encoded-traversal payloads produce an actual escape on
Linux (verified empirically: they pass identically with the fix
reverted). isWithinProjectRoot now takes an optional path-module
override (defaulting to the host's native node:path, so every existing
caller is unchanged) so its win32 and posix branches can be unit
tested directly from one OS, the same pattern already used by
producer/fileServer.ts's isPathInside. New tests cover a backslash
traversal, a mixed backslash/forward-slash traversal, and a
drive-letter path escaping the root under path.win32, plus the
existing posix case -- confirmed each one fails when the check is
removed. The HTTP-level tests stay as-is: real regression coverage for
them comes from the windows-latest CI job, which runs both new
pathSafety suites unfiltered.

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: orphan4183 <kingenmaww@gmail.com>
2026-09-22 22:18:56 -04:00

101 lines
3.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import { createStudioServer, type StudioServer } from "./studioServer.js";
import {
cleanupStudioServerRoot,
makeStudioServerRoot,
writeStudioIndexHtml,
} from "./studioServerTestFixture.js";
/**
* Cache policy for the studio bundle, decided by ROUTE. Vite emits only
* content-hashed files under dist/assets, so that path can be immutable;
* public/ files (icons, favicon) keep their names across builds and the HTML
* shell names the current bundle, so both must revalidate.
*/
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.
// fallow-ignore-next-line code-duplication
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),
};
});
// A real filename from `bun run --filter @hyperframes/studio build`.
const HASHED_BUNDLE = "index-BRr1JoHX.js";
const BUNDLE_BYTES = "export const studio = 1;";
// Hand-authored, hyphenated, carrying capitals and digits — the shape no
// filename heuristic can separate from a rollup hash. It lives under /icons/,
// so the route answers correctly without having to.
const PUBLIC_ICON = "brand-Logo2026x.svg";
let root: string;
let server: StudioServer;
beforeEach(() => {
const fixture = makeStudioServerRoot("hf-studio-assets-");
root = fixture.root;
hooks.studioDir = fixture.studioDir;
fs.mkdirSync(path.join(hooks.studioDir, "icons"), { recursive: true });
fs.writeFileSync(path.join(hooks.studioDir, "assets", HASHED_BUNDLE), BUNDLE_BYTES);
fs.writeFileSync(path.join(hooks.studioDir, "icons", PUBLIC_ICON), "<svg/>");
fs.writeFileSync(path.join(hooks.studioDir, "favicon.svg"), "<svg/>");
writeStudioIndexHtml(hooks.studioDir);
server = createStudioServer({ projectDir: fixture.projectDir });
});
afterEach(() => {
cleanupStudioServerRoot(server, root, () => (hooks.studioDir = ""));
});
describe("studio bundle cache policy", () => {
it("serves everything under /assets/ as immutable", async () => {
const response = await server.app.request(`/assets/${HASHED_BUNDLE}`);
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
});
it("does not make a public/ icon immutable, however hash-like its name", async () => {
const response = await server.app.request(`/icons/${PUBLIC_ICON}`);
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("no-store");
});
it("keeps the favicon revalidating", async () => {
const response = await server.app.request("/favicon.svg");
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("no-store");
});
it("revalidates the HTML shell without evicting it from bfcache", async () => {
const response = await server.app.request("/");
expect(response.status).toBe(200);
// `no-cache`, not `no-store`: same refetch, but `no-store` would blocklist
// the document from Chrome's bfcache and cold-boot Studio on Back.
expect(response.headers.get("Cache-Control")).toBe("no-cache");
});
it("sends the bundle uncompressed, which loopback measures faster", async () => {
const response = await server.app.request(`/assets/${HASHED_BUNDLE}`, {
headers: { "Accept-Encoding": "gzip, deflate, br" },
});
expect(response.headers.get("Content-Encoding")).toBeNull();
expect(await response.text()).toBe(BUNDLE_BYTES);
});
});