mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user