From 52154bddb5d493c3813a9d1634868fcf6ca4c7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 28 Sep 2026 19:24:05 +0000 Subject: [PATCH] fix(studio-server): answer 413/415 instead of reading huge or binary files as text (#4677) * fix(studio-server): answer 413/415 instead of reading huge or binary files as text * test(studio-server): prove the 413 path never reads the file via peak RSS --- .../studio-server/src/routes/files.test.ts | 39 ++++++++++++++++++- packages/studio-server/src/routes/files.ts | 12 +++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 6ee0e8e482..26f98d6679 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -3,9 +3,12 @@ import { Hono } from "hono"; import { parseHTML } from "linkedom"; import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { + closeSync, existsSync, + ftruncateSync, mkdirSync, mkdtempSync, + openSync, readFileSync, readdirSync, rmSync, @@ -297,6 +300,38 @@ describe("registerFileRoutes", () => { expect(response.status).toBe(404); }); + it("answers 413 for a file over the text cap without reading it", async () => { + const projectDir = createProjectDir(); + const fd = openSync(join(projectDir, "big.mp4"), "w"); + ftruncateSync(fd, 300 * 1024 * 1024); + closeSync(fd); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const peakRssKbBefore = process.resourceUsage().maxRSS; + const response = await app.request("http://localhost/projects/demo/files/big.mp4"); + const body = await response.text(); + + expect(response.status).toBe(413); + expect(JSON.parse(body)).toMatchObject({ why: "too_large" }); + expect(body.length).toBeLessThan(1024); + expect(process.resourceUsage().maxRSS - peakRssKbBefore).toBeLessThan(100 * 1024); + }); + + it("answers 415 for a binary file", async () => { + const projectDir = createProjectDir(); + const header = Buffer.from([0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70, 0xff, 0xfe]); + writeFileSync(join(projectDir, "clip.mp4"), Buffer.concat([header, Buffer.alloc(4096, 0xab)])); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo/files/clip.mp4"); + + expect(response.status).toBe(415); + expect(await response.json()).toMatchObject({ why: "binary", version: expect.any(String) }); + expect(response.headers.get("etag")).toBeTruthy(); + }); + it("returns the same strong content version in JSON and ETag", async () => { const projectDir = createProjectDir(); const app = new Hono(); @@ -324,7 +359,8 @@ describe("registerFileRoutes", () => { expect(readFileSync(join(projectDir, "assets/image.png"))).toEqual(bytes); const read = await app.request(url); const payload = await read.json(); - expect(payload.content).toBe(bytes.toString("utf-8")); + expect(read.status).toBe(415); + expect(payload.content).toBeUndefined(); expect(payload.version).toBe(fileContentVersion(bytes)); expect(read.headers.get("etag")).toBe(payload.version); const duplicate = await app.request(url, { @@ -366,6 +402,7 @@ describe("registerFileRoutes", () => { writeFileSync(path, before); const url = "http://localhost/projects/demo/files/image.png"; const read = await app.request(url); + expect(read.status).toBe(415); const version = read.headers.get("etag")!; // Invalid UTF-8 bytes may decode to the same string, but must not share a version. const staleBytes = Buffer.from([0xfe, 0, 0x80, 0x81]); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index bb8d99d443..06c5947ada 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -2342,6 +2342,9 @@ async function processUploadedFiles( // ── Route registration ────────────────────────────────────────────────────── +const MAX_TEXT_READ_BYTES = 32 * 1024 * 1024; +const GIT_BINARY_SNIFF_BYTES = 8000; + export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { // ── Read ── @@ -2376,13 +2379,20 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { // Hono answers as a plain-text 500. The caller already handles a 404 // with `why`; this reports the same shape instead of an opaque server // error for something that is not one. - if (!fstatSync(fd).isFile()) { + const stat = fstatSync(fd); + if (!stat.isFile()) { return c.json({ error: "not found", why: "not_a_file" }, 404); } + if (stat.size > MAX_TEXT_READ_BYTES) { + return c.json({ error: "too large to read as text", why: "too_large" }, 413); + } const content = readFileSync(fd); const version = fileContentVersion(content); c.header("ETag", version); + if (content.subarray(0, GIT_BINARY_SNIFF_BYTES).includes(0)) { + return c.json({ error: "not a text file", why: "binary", version }, 415); + } // `missing: false` on the read path too, so its PRESENCE is what tells a // caller this server distinguishes the two empty answers at all. Without // it here, a real 0-byte file from a new server looks exactly like either