Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions .github/workflows/feature-to-dev-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
- main
- dev
- "dependabot/**"
# Cloud-agent branches already open a PR into main as the human owner.
# Auto-opening a second PR into dev (authored by github-actions, titled
# with the cursor/ prefix) duplicates review and is not wanted.
- "cursor/**"

jobs:
create-pull-request:
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
},
"pnpm": {
"overrides": {
"postcss": "^8.5.18",
"postcss": "^8.5.23",
"esbuild": "^0.25.12",
"ws": "^8.20.1",
"@eslint/plugin-kit": "^0.3.4",
Expand All @@ -36,7 +36,7 @@
"undici": "^6.27.0",
"sharp": "^0.35.0",
"vite": "^7.3.5",
"brace-expansion": "^5.0.8"
"brace-expansion": "^5.0.9"
}
}
}
3 changes: 1 addition & 2 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@
"@trpc/react-query": "11.18.0",
"@trpc/server": "11.18.0",
"drizzle-orm": "0.45.2",
"image-size": "2.0.2",
"sanitize-html": "2.17.4",
"sanitize-html": "2.17.5",
"stripe": "^22.0.0",
"superjson": "2.2.3",
"zod": "3.25.53"
Expand Down
10 changes: 5 additions & 5 deletions packages/api/src/routers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { eq } from "drizzle-orm";
import { CacheKeys } from "../middleware/cache";
import type { DrizzleDB } from "@query/db";
import { fetchPortalContext } from "../services/portal-context";
import { readImageDimensions } from "../services/image-dimensions";

// z.string().url() is backed by new URL(), which accepts any scheme — a stored
// data: or javascript: URI is handed straight back to whoever renders it.
Expand Down Expand Up @@ -185,9 +186,8 @@ export const userRouter = createTRPCRouter({
const buffer = Buffer.from(base64Data, "base64");

try {
const { imageSize } = await import("image-size");
const dimensions = imageSize(buffer);
if (!dimensions.width || !dimensions.height) {
const dimensions = readImageDimensions(buffer);
if (!dimensions?.width || !dimensions.height) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid image dimensions. File may be corrupt.",
Expand All @@ -200,8 +200,8 @@ export const userRouter = createTRPCRouter({
"Image dimensions exceed the maximum allowed size of 2000x2000 pixels.",
});
}
const allowedTypes = ["jpg", "jpeg", "png", "webp"];
if (!dimensions.type || !allowedTypes.includes(dimensions.type)) {
const allowedTypes = ["jpeg", "png", "webp"];
if (!allowedTypes.includes(dimensions.type)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
Expand Down
81 changes: 81 additions & 0 deletions packages/api/src/services/image-dimensions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { readImageDimensions } from "./image-dimensions";

const png = (width: number, height: number) => {
const buf = Buffer.alloc(24);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf);
buf.writeUInt32BE(13, 8);
buf.write("IHDR", 12);
buf.writeUInt32BE(width, 16);
buf.writeUInt32BE(height, 20);
return buf;
};

const jpegSof = (width: number, height: number) => {
// SOI (2) + SOF0 marker (2) + length-inclusive segment (11)
const buf = Buffer.alloc(15);
buf[0] = 0xff;
buf[1] = 0xd8;
buf[2] = 0xff;
buf[3] = 0xc0;
buf.writeUInt16BE(11, 4);
buf[6] = 8;
buf.writeUInt16BE(height, 7);
buf.writeUInt16BE(width, 9);
buf[11] = 1;
return buf;
};

const webpVp8x = (width: number, height: number) => {
const buf = Buffer.alloc(30);
buf.write("RIFF", 0);
buf.writeUInt32LE(22, 4);
buf.write("WEBP", 8);
buf.write("VP8X", 12);
buf.writeUInt32LE(10, 16);
const w = width - 1;
const h = height - 1;
buf[24] = w & 0xff;
buf[25] = (w >> 8) & 0xff;
buf[26] = (w >> 16) & 0xff;
buf[27] = h & 0xff;
buf[28] = (h >> 8) & 0xff;
buf[29] = (h >> 16) & 0xff;
return buf;
};

describe("readImageDimensions", () => {
it("reads PNG IHDR width and height", () => {
expect(readImageDimensions(png(640, 480))).toEqual({
width: 640,
height: 480,
type: "png",
});
});

it("reads JPEG SOF0 width and height", () => {
expect(readImageDimensions(jpegSof(32, 16))).toEqual({
width: 32,
height: 16,
type: "jpeg",
});
});

it("reads WebP VP8X canvas size", () => {
expect(readImageDimensions(webpVp8x(200, 100))).toEqual({
width: 200,
height: 100,
type: "webp",
});
});

it("refuses zero-sized and truncated buffers instead of looping", () => {
expect(readImageDimensions(png(0, 10))).toBeNull();
expect(readImageDimensions(Buffer.from("icns"))).toBeNull();
expect(readImageDimensions(Buffer.alloc(0))).toBeNull();
// A zero-size JXL/HEIF box used to hang image-size. We never parse those.
const jxlish = Buffer.alloc(32, 0);
jxlish.write("JXL ", 4);
expect(readImageDimensions(jxlish)).toBeNull();
});
});
140 changes: 140 additions & 0 deletions packages/api/src/services/image-dimensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Dimensions for the three types the profile-image upload already allows.
*
* `image-size` through 2.0.2 (the latest published release) infinite-loops on
* crafted ICNS / JXL / HEIF buffers. There is no patched version on npm, so
* this parser understands only PNG, JPEG and WebP — the same types the data-URI
* regex already admits. Anything else is corrupt, not "try the next format".
*/

export type ImageKind = "png" | "jpeg" | "webp";

export type ImageDimensions = {
width: number;
height: number;
type: ImageKind;
};

const PNG_SIG = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);

const isFinitePositive = (n: number) =>
Number.isInteger(n) && n > 0 && n <= 0xffff_ffff;

const pngDimensions = (buf: Buffer): ImageDimensions | null => {
if (buf.length < 24) return null;
if (!buf.subarray(0, 8).equals(PNG_SIG)) return null;
if (buf.toString("ascii", 12, 16) !== "IHDR") return null;
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
if (!isFinitePositive(width) || !isFinitePositive(height)) return null;
return { width, height, type: "png" };
};

const jpegDimensions = (buf: Buffer): ImageDimensions | null => {
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;

let offset = 2;
while (offset + 3 < buf.length) {
if (buf[offset] !== 0xff) return null;
while (offset < buf.length && buf[offset] === 0xff) offset += 1;
if (offset >= buf.length) return null;

const marker = buf[offset]!;
offset += 1;

// Standalone markers (no length): RST0–RST7, SOI, EOI, TEM.
if (
marker === 0xd8 ||
marker === 0xd9 ||
marker === 0x01 ||
(marker >= 0xd0 && marker <= 0xd7)
) {
if (marker === 0xd9) return null;
continue;
}

if (offset + 1 >= buf.length) return null;
const length = buf.readUInt16BE(offset);
if (length < 2 || offset + length > buf.length) return null;

// SOF0–SOF3, SOF5–SOF7, SOF9–SOF11, SOF13–SOF15 carry the frame size.
const isSof =
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf);

if (isSof) {
if (length < 7 || offset + 6 >= buf.length) return null;
const height = buf.readUInt16BE(offset + 3);
const width = buf.readUInt16BE(offset + 5);
if (!isFinitePositive(width) || !isFinitePositive(height)) return null;
return { width, height, type: "jpeg" };
}

offset += length;
}

return null;
};

const readUInt24LE = (buf: Buffer, offset: number) =>
buf[offset]! | (buf[offset + 1]! << 8) | (buf[offset + 2]! << 16);

const webpDimensions = (buf: Buffer): ImageDimensions | null => {
if (buf.length < 16) return null;
if (buf.toString("ascii", 0, 4) !== "RIFF") return null;
if (buf.toString("ascii", 8, 12) !== "WEBP") return null;

const fourcc = buf.toString("ascii", 12, 16);
if (buf.length < 20) return null;
const chunkSize = buf.readUInt32LE(16);
const payload = 20;

if (fourcc === "VP8X") {
// 1 byte flags + 3 reserved + 3 width-1 + 3 height-1
if (chunkSize < 10 || buf.length < payload + 10) return null;
const width = readUInt24LE(buf, payload + 4) + 1;
const height = readUInt24LE(buf, payload + 7) + 1;
if (!isFinitePositive(width) || !isFinitePositive(height)) return null;
return { width, height, type: "webp" };
}

if (fourcc === "VP8L") {
// signature 0x2f, then 14-bit width-1 and 14-bit height-1.
if (chunkSize < 5 || buf.length < payload + 5) return null;
if (buf[payload] !== 0x2f) return null;
const bits =
buf[payload + 1]! |
(buf[payload + 2]! << 8) |
(buf[payload + 3]! << 16) |
(buf[payload + 4]! << 24);
const width = (bits & 0x3fff) + 1;
const height = ((bits >> 14) & 0x3fff) + 1;
if (!isFinitePositive(width) || !isFinitePositive(height)) return null;
return { width, height, type: "webp" };
}

if (fourcc === "VP8 ") {
// 3-byte frame tag, then 0x9d 0x01 0x2a, then 16-bit width/height (14 used).
if (chunkSize < 10 || buf.length < payload + 10) return null;
if (
buf[payload + 3] !== 0x9d ||
buf[payload + 4] !== 0x01 ||
buf[payload + 5] !== 0x2a
) {
return null;
}
const width = buf.readUInt16LE(payload + 6) & 0x3fff;
const height = buf.readUInt16LE(payload + 8) & 0x3fff;
if (!isFinitePositive(width) || !isFinitePositive(height)) return null;
return { width, height, type: "webp" };
}

return null;
};

export const readImageDimensions = (buf: Buffer): ImageDimensions | null =>
pngDimensions(buf) ?? jpegDimensions(buf) ?? webpDimensions(buf);
Loading
Loading