Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions apps/server/src/imageMime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,42 @@ describe("imageMime", () => {
});
});

it("rejects payload with characters outside the base64 alphabet", () => {
expect(parseBase64DataUrl("data:image/png;base64,SGVs!bG8=")).toBeNull();
expect(parseBase64DataUrl("data:image/png;base64,SGVs,bG8=")).toBeNull();
});

it("rejects empty and whitespace-only payloads", () => {
expect(parseBase64DataUrl("data:image/png;base64,")).toBeNull();
expect(parseBase64DataUrl("data:image/png;base64, \r\n")).toBeNull();
});

it("parses a case-insensitive scheme and mime type", () => {
expect(parseBase64DataUrl("DATA:IMAGE/PNG;BASE64,SGVsbG8=")).toEqual({
mimeType: "image/png",
base64: "SGVsbG8=",
});
});

it("parses a multi-megabyte payload from a deep call stack", () => {
// Regression: matching the payload with a regex borrowed the JS call
// stack, so a ~10 MB image parsed inside fiber execution threw
// "RangeError: Maximum call stack size exceeded".
const dataUrl = `data:image/png;base64,${"A".repeat(14_000_000)}`;
const atDepth = (depth: number): ReturnType<typeof parseBase64DataUrl> =>
depth === 0 ? parseBase64DataUrl(dataUrl) : atDepth(depth - 1);
const findMaxDepth = (depth: number): number => {
try {
return findMaxDepth(depth + 1);
} catch {
return depth;
}
};
const result = atDepth(Math.floor(findMaxDepth(0) * 0.85));
expect(result?.mimeType).toBe("image/png");
expect(result?.base64.length).toBe(14_000_000);
});

it("does not read inherited keys from mime extension map", () => {
expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin");
});
Expand Down
62 changes: 54 additions & 8 deletions apps/server/src/imageMime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,43 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
".webp",
]);

// Whether `code` is a character the base64 payload may contain, aside from
// the whitespace handled separately below.
function isBase64Char(code: number): boolean {
return (
(code >= 0x61 && code <= 0x7a) || // a-z
(code >= 0x41 && code <= 0x5a) || // A-Z
(code >= 0x30 && code <= 0x39) || // 0-9
code === 0x2b || // +
code === 0x2f || // /
code === 0x3d // =
);
}

function isBase64Whitespace(code: number): boolean {
return code === 0x0d || code === 0x0a || code === 0x20; // \r \n space
}

// Data URLs carry the full image payload, so this parser must never run a
// regex across the payload: V8's regex engine borrows the JS call stack, and
// matching a multi-megabyte string from a deep call stack (e.g. inside fiber
// execution) throws "Maximum call stack size exceeded".
export function parseBase64DataUrl(
dataUrl: string,
): { readonly mimeType: string; readonly base64: string } | null {
const match = /^data:([^,]+),([a-z0-9+/=\r\n ]+)$/i.exec(dataUrl.trim());
if (!match) return null;
const trimmed = dataUrl.trim();
if (trimmed.slice(0, 5).toLowerCase() !== "data:") return null;

const commaIndex = trimmed.indexOf(",");
if (commaIndex === -1) return null;
const header = trimmed.slice(5, commaIndex);
if (header.length === 0) return null;

const headerParts: Array<string> = [];
for (const part of (match[1] ?? "").split(";")) {
const trimmed = part.trim();
if (trimmed.length > 0) {
headerParts.push(trimmed);
for (const part of header.split(";")) {
const partTrimmed = part.trim();
if (partTrimmed.length > 0) {
headerParts.push(partTrimmed);
}
}
if (headerParts.length < 2) {
Expand All @@ -51,8 +77,28 @@ export function parseBase64DataUrl(
}

const mimeType = headerParts[0]?.toLowerCase();
const base64 = match[2]?.replace(/\s+/g, "");
if (!mimeType || !base64) return null;
if (!mimeType) return null;

const payload = trimmed.slice(commaIndex + 1);
const runs: Array<string> = [];
let runStart = -1;
for (let index = 0; index < payload.length; index += 1) {
const code = payload.charCodeAt(index);
if (isBase64Char(code)) {
if (runStart === -1) runStart = index;
continue;
}
if (!isBase64Whitespace(code)) return null;
if (runStart !== -1) {
runs.push(payload.slice(runStart, index));
runStart = -1;
}
}
if (runStart !== -1) {
runs.push(payload.slice(runStart));
}
const base64 = runs.length === 1 ? runs[0]! : runs.join("");
if (base64.length === 0) return null;

return { mimeType, base64 };
}
Expand Down
Loading