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
This commit is contained in:
Miguel Ángel
2026-09-28 19:24:05 +00:00
committed by GitHub
parent 46eedd4dfb
commit 52154bddb5
2 changed files with 49 additions and 2 deletions
@@ -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]);
+11 -1
View File
@@ -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