authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-17 12:47:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-17 21:47:52+02:00
loged81c156daf3d8ce7d3599236df7a26c11ef145f
treeda876939a74369b329979ed56034d1d08582fc10
parent7dc75127c2320a64d8148ad7a09fd30db892d4af
signature Signed by PGP key B5690EEEBB952194

fix(server): image upload crashed dispatchCommand with a stack overflow (#3952)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

2 files changed, 120 insertions(+), 8 deletions(-)

apps/server/src/imageMime.test.ts+57
...@@ -32,6 +32,63 @@ describe("imageMime", () => {...@@ -32,6 +32,63 @@ describe("imageMime", () => {
32 });32 });
33 });33 });
3434
35 it("rejects payload with characters outside the base64 alphabet", () => {
36 expect(parseBase64DataUrl("data:image/png;base64,SGVs!bG8=")).toBeNull();
37 expect(parseBase64DataUrl("data:image/png;base64,SGVs,bG8=")).toBeNull();
38 });
39
40 it("rejects structurally malformed base64", () => {
41 // '=' before the trailing padding position
42 expect(parseBase64DataUrl("data:image/png;base64,AB=CD===")).toBeNull();
43 expect(parseBase64DataUrl("data:image/png;base64,SGV=bG8=")).toBeNull();
44 // more than two padding characters
45 expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8=====AAA")).toBeNull();
46 // length not a multiple of 4
47 expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8")).toBeNull();
48 });
49
50 it("accepts base64 with one or two trailing padding characters", () => {
51 expect(parseBase64DataUrl("data:image/png;base64,SGVsbA==")).toEqual({
52 mimeType: "image/png",
53 base64: "SGVsbA==",
54 });
55 expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8h")).toEqual({
56 mimeType: "image/png",
57 base64: "SGVsbG8h",
58 });
59 });
60
61 it("rejects empty and whitespace-only payloads", () => {
62 expect(parseBase64DataUrl("data:image/png;base64,")).toBeNull();
63 expect(parseBase64DataUrl("data:image/png;base64, \r\n")).toBeNull();
64 });
65
66 it("parses a case-insensitive scheme and mime type", () => {
67 expect(parseBase64DataUrl("DATA:IMAGE/PNG;BASE64,SGVsbG8=")).toEqual({
68 mimeType: "image/png",
69 base64: "SGVsbG8=",
70 });
71 });
72
73 it("parses a multi-megabyte payload from a deep call stack", () => {
74 // Regression: matching the payload with a regex borrowed the JS call
75 // stack, so a ~10 MB image parsed inside fiber execution threw
76 // "RangeError: Maximum call stack size exceeded".
77 const dataUrl = `data:image/png;base64,${"A".repeat(14_000_000)}`;
78 const atDepth = (depth: number): ReturnType<typeof parseBase64DataUrl> =>
79 depth === 0 ? parseBase64DataUrl(dataUrl) : atDepth(depth - 1);
80 const findMaxDepth = (depth: number): number => {
81 try {
82 return findMaxDepth(depth + 1);
83 } catch {
84 return depth;
85 }
86 };
87 const result = atDepth(Math.floor(findMaxDepth(0) * 0.85));
88 expect(result?.mimeType).toBe("image/png");
89 expect(result?.base64.length).toBe(14_000_000);
90 });
91
35 it("does not read inherited keys from mime extension map", () => {92 it("does not read inherited keys from mime extension map", () => {
36 expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin");93 expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin");
37 });94 });
apps/server/src/imageMime.ts+63-8
...@@ -29,17 +29,43 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([...@@ -29,17 +29,43 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
29 ".webp",29 ".webp",
30]);30]);
3131
32// Whether `code` is a character the base64 payload may contain, aside from
33// the whitespace handled separately below.
34function isBase64Char(code: number): boolean {
35 return (
36 (code >= 0x61 && code <= 0x7a) || // a-z
37 (code >= 0x41 && code <= 0x5a) || // A-Z
38 (code >= 0x30 && code <= 0x39) || // 0-9
39 code === 0x2b || // +
40 code === 0x2f || // /
41 code === 0x3d // =
42 );
43}
44
45function isBase64Whitespace(code: number): boolean {
46 return code === 0x0d || code === 0x0a || code === 0x20; // \r \n space
47}
48
49// Data URLs carry the full image payload, so this parser must never run a
50// regex across the payload: V8's regex engine borrows the JS call stack, and
51// matching a multi-megabyte string from a deep call stack (e.g. inside fiber
52// execution) throws "Maximum call stack size exceeded".
32export function parseBase64DataUrl(53export function parseBase64DataUrl(
33 dataUrl: string,54 dataUrl: string,
34): { readonly mimeType: string; readonly base64: string } | null {55): { readonly mimeType: string; readonly base64: string } | null {
35 const match = /^data:([^,]+),([a-z0-9+/=\r\n ]+)$/i.exec(dataUrl.trim());56 const trimmed = dataUrl.trim();
36 if (!match) return null;57 if (trimmed.slice(0, 5).toLowerCase() !== "data:") return null;
58
59 const commaIndex = trimmed.indexOf(",");
60 if (commaIndex === -1) return null;
61 const header = trimmed.slice(5, commaIndex);
62 if (header.length === 0) return null;
3763
38 const headerParts: Array<string> = [];64 const headerParts: Array<string> = [];
39 for (const part of (match[1] ?? "").split(";")) {65 for (const part of header.split(";")) {
40 const trimmed = part.trim();66 const partTrimmed = part.trim();
41 if (trimmed.length > 0) {67 if (partTrimmed.length > 0) {
42 headerParts.push(trimmed);68 headerParts.push(partTrimmed);
43 }69 }
44 }70 }
45 if (headerParts.length < 2) {71 if (headerParts.length < 2) {
...@@ -51,8 +77,37 @@ export function parseBase64DataUrl(...@@ -51,8 +77,37 @@ export function parseBase64DataUrl(
51 }77 }
5278
53 const mimeType = headerParts[0]?.toLowerCase();79 const mimeType = headerParts[0]?.toLowerCase();
54 const base64 = match[2]?.replace(/\s+/g, "");80 if (!mimeType) return null;
55 if (!mimeType || !base64) return null;81
82 const payload = trimmed.slice(commaIndex + 1);
83 const runs: Array<string> = [];
84 let runStart = -1;
85 for (let index = 0; index < payload.length; index += 1) {
86 const code = payload.charCodeAt(index);
87 if (isBase64Char(code)) {
88 if (runStart === -1) runStart = index;
89 continue;
90 }
91 if (!isBase64Whitespace(code)) return null;
92 if (runStart !== -1) {
93 runs.push(payload.slice(runStart, index));
94 runStart = -1;
95 }
96 }
97 if (runStart !== -1) {
98 runs.push(payload.slice(runStart));
99 }
100 const base64 = runs.length === 1 ? runs[0]! : runs.join("");
101 if (base64.length === 0 || base64.length % 4 !== 0) return null;
102 const firstPad = base64.indexOf("=");
103 if (firstPad !== -1) {
104 // '=' is only valid as one or two trailing padding characters; Node's
105 // decoder would otherwise silently truncate at the first '='.
106 if (base64.length - firstPad > 2) return null;
107 for (let index = firstPad; index < base64.length; index += 1) {
108 if (base64.charCodeAt(index) !== 0x3d) return null;
109 }
110 }
56111
57 return { mimeType, base64 };112 return { mimeType, base64 };
58}113}