| author | |
| committer | |
| log | ed81c156daf3d8ce7d3599236df7a26c11ef145f |
| tree | da876939a74369b329979ed56034d1d08582fc10 |
| parent | 7dc75127c2320a64d8148ad7a09fd30db892d4af |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 32 | }); |
| 33 | 33 | }); |
| 34 | 34 | |
| 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 | 92 | it("does not read inherited keys from mime extension map", () => { |
| 36 | 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 | 29 | ".webp", |
| 30 | 30 | ]); |
| 31 | 31 | |
| 32 | // Whether `code` is a character the base64 payload may contain, aside from | |
| 33 | // the whitespace handled separately below. | |
| 34 | function 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 | ||
| 45 | function 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". | |
| 32 | 53 | export function parseBase64DataUrl( |
| 33 | 54 | dataUrl: string, |
| 34 | 55 | ): { readonly mimeType: string; readonly base64: string } | null { |
| 35 | const match = /^data:([^,]+),([a-z0-9+/=\r\n ]+)$/i.exec(dataUrl.trim()); | |
| 36 | if (!match) return null; | |
| 56 | const trimmed = dataUrl.trim(); | |
| 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; | |
| 37 | 63 | |
| 38 | 64 | const headerParts: Array<string> = []; |
| 39 | for (const part of (match[1] ?? "").split(";")) { | |
| 40 | const trimmed = part.trim(); | |
| 41 | if (trimmed.length > 0) { | |
| 42 | headerParts.push(trimmed); | |
| 65 | for (const part of header.split(";")) { | |
| 66 | const partTrimmed = part.trim(); | |
| 67 | if (partTrimmed.length > 0) { | |
| 68 | headerParts.push(partTrimmed); | |
| 43 | 69 | } |
| 44 | 70 | } |
| 45 | 71 | if (headerParts.length < 2) { |
| ... | ... | @@ -51,8 +77,37 @@ export function parseBase64DataUrl( |
| 51 | 77 | } |
| 52 | 78 | |
| 53 | 79 | const mimeType = headerParts[0]?.toLowerCase(); |
| 54 | const base64 = match[2]?.replace(/\s+/g, ""); | |
| 55 | if (!mimeType || !base64) return null; | |
| 80 | if (!mimeType) 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 | } | |
| 56 | 111 | |
| 57 | 112 | return { mimeType, base64 }; |
| 58 | 113 | } |