Restore the community photo after reload and reject empty, oversized, SVG, and spoofed uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-09-10 15:50:32 -06:00
co-authored by Cursor
parent 6ccc1e8c8e
commit 234f3998ad
18 changed files with 1072 additions and 112 deletions
+402
View File
@@ -0,0 +1,402 @@
import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose";
/** Community avatar cap (bytes). */
const COMMUNITY_AVATAR_MAX_BYTES = 5 * 1024 * 1024;
/** Custom-method attachment cap (bytes). */
const CUSTOM_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
export const COMMUNITY_AVATAR_ACCEPT =
"image/jpeg,image/png,image/webp,image/gif";
export const CUSTOM_ATTACHMENT_ACCEPT = `${COMMUNITY_AVATAR_ACCEPT},application/pdf`;
export type CreateFlowUploadValidationReason =
| "empty"
| "tooLarge"
| "svg"
| "invalidType"
| "undecodable";
type SniffedUploadKind =
| "jpeg"
| "png"
| "gif"
| "webp"
| "pdf"
| "svg"
| "empty"
| "unknown";
type CreateFlowUploadRasterKind = "jpeg" | "png" | "gif" | "webp";
type CreateFlowUploadValidationOk = {
ok: true;
kind: CreateFlowUploadRasterKind | "pdf";
mimeType: string;
};
type CreateFlowUploadValidationFail = {
ok: false;
reason: CreateFlowUploadValidationReason;
};
export type CreateFlowUploadValidationResult =
| CreateFlowUploadValidationOk
| CreateFlowUploadValidationFail;
export class CreateFlowUploadValidationError extends Error {
readonly reason: CreateFlowUploadValidationReason;
constructor(reason: CreateFlowUploadValidationReason) {
super(reason);
this.name = "CreateFlowUploadValidationError";
this.reason = reason;
}
}
const SVG_MIME = new Set(["image/svg+xml", "image/svg"]);
export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number {
return purpose === "communityAvatar"
? COMMUNITY_AVATAR_MAX_BYTES
: CUSTOM_ATTACHMENT_MAX_BYTES;
}
function mimeTypeForSniffedKind(
kind: CreateFlowUploadRasterKind | "pdf",
): string {
switch (kind) {
case "jpeg":
return "image/jpeg";
case "png":
return "image/png";
case "gif":
return "image/gif";
case "webp":
return "image/webp";
case "pdf":
return "application/pdf";
}
}
export function messageKeyForCreateFlowUploadReason(
reason: CreateFlowUploadValidationReason,
): `errors.${CreateFlowUploadValidationReason}` {
return `errors.${reason}`;
}
function fileLooksLikeSvg(file: File): boolean {
const declared = file.type.toLowerCase().split(";")[0]?.trim() ?? "";
if (SVG_MIME.has(declared)) return true;
return /\.svgz?$/i.test(file.name);
}
function readU16LE(bytes: Uint8Array, offset: number): number {
return bytes[offset]! | (bytes[offset + 1]! << 8);
}
function readU16BE(bytes: Uint8Array, offset: number): number {
return (bytes[offset]! << 8) | bytes[offset + 1]!;
}
function readU24LE(bytes: Uint8Array, offset: number): number {
return (
bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16)
);
}
function readU32BE(bytes: Uint8Array, offset: number): number {
return (
((bytes[offset]! << 24) |
(bytes[offset + 1]! << 16) |
(bytes[offset + 2]! << 8) |
bytes[offset + 3]!) >>>
0
);
}
function startsWith(bytes: Uint8Array, offset: number, ascii: string): boolean {
if (offset + ascii.length > bytes.length) return false;
for (let i = 0; i < ascii.length; i++) {
if (bytes[offset + i] !== ascii.charCodeAt(i)) return false;
}
return true;
}
function looksLikeSvgBytes(bytes: Uint8Array): boolean {
let i = 0;
if (
bytes.length >= 3 &&
bytes[0] === 0xef &&
bytes[1] === 0xbb &&
bytes[2] === 0xbf
) {
i = 3;
}
while (
i < bytes.length &&
(bytes[i] === 0x20 ||
bytes[i] === 0x09 ||
bytes[i] === 0x0d ||
bytes[i] === 0x0a)
) {
i += 1;
}
const head = new TextDecoder("utf-8", { fatal: false })
.decode(bytes.subarray(i, Math.min(i + 512, bytes.length)))
.toLowerCase();
return head.includes("<svg") || head.includes("<!doctype svg");
}
/**
* True-type sniff from magic bytes. Raster/PDF signatures win over a later
* `<svg` substring so binary comments cannot be classified as SVG.
*/
function sniffCreateFlowUploadBytes(
bytes: Uint8Array,
): SniffedUploadKind {
if (bytes.length === 0) return "empty";
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return "jpeg";
}
if (
bytes.length >= 8 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47 &&
bytes[4] === 0x0d &&
bytes[5] === 0x0a &&
bytes[6] === 0x1a &&
bytes[7] === 0x0a
) {
return "png";
}
if (startsWith(bytes, 0, "GIF87a") || startsWith(bytes, 0, "GIF89a")) {
return "gif";
}
if (
bytes.length >= 12 &&
startsWith(bytes, 0, "RIFF") &&
startsWith(bytes, 8, "WEBP")
) {
return "webp";
}
if (bytes.length >= 5 && startsWith(bytes, 0, "%PDF-")) {
return "pdf";
}
if (looksLikeSvgBytes(bytes)) return "svg";
return "unknown";
}
function pngDimensions(
bytes: Uint8Array,
): { width: number; height: number } | null {
if (bytes.length < 24 || !startsWith(bytes, 12, "IHDR")) return null;
const width = readU32BE(bytes, 16);
const height = readU32BE(bytes, 20);
if (width < 1 || height < 1) return null;
return { width, height };
}
function gifDimensions(
bytes: Uint8Array,
): { width: number; height: number } | null {
if (bytes.length < 10) return null;
const width = readU16LE(bytes, 6);
const height = readU16LE(bytes, 8);
if (width < 1 || height < 1) return null;
return { width, height };
}
function jpegDimensions(
bytes: Uint8Array,
): { width: number; height: number } | null {
if (bytes.length < 4) return null;
let offset = 2;
while (offset + 8 < bytes.length) {
if (bytes[offset] !== 0xff) {
offset += 1;
continue;
}
const marker = bytes[offset + 1]!;
if (marker === 0xff) {
offset += 1;
continue;
}
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
offset += 2;
continue;
}
if (offset + 3 >= bytes.length) return null;
const size = readU16BE(bytes, offset + 2);
if (size < 2) return null;
const isSof =
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf);
if (isSof) {
if (offset + 8 >= bytes.length) return null;
const height = readU16BE(bytes, offset + 5);
const width = readU16BE(bytes, offset + 7);
if (width < 1 || height < 1) return null;
return { width, height };
}
offset += 2 + size;
}
return null;
}
function webpDimensions(
bytes: Uint8Array,
): { width: number; height: number } | null {
if (bytes.length < 20) return null;
if (startsWith(bytes, 12, "VP8X")) {
if (bytes.length < 30) return null;
const width = readU24LE(bytes, 24) + 1;
const height = readU24LE(bytes, 27) + 1;
if (width < 1 || height < 1) return null;
return { width, height };
}
if (startsWith(bytes, 12, "VP8L")) {
if (bytes.length < 25 || bytes[20] !== 0x2f) return null;
const bits =
bytes[21]! |
(bytes[22]! << 8) |
(bytes[23]! << 16) |
(bytes[24]! << 24);
const width = (bits & 0x3fff) + 1;
const height = ((bits >> 14) & 0x3fff) + 1;
if (width < 1 || height < 1) return null;
return { width, height };
}
if (startsWith(bytes, 12, "VP8 ")) {
if (bytes.length < 30) return null;
if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) {
return null;
}
const width = readU16LE(bytes, 26) & 0x3fff;
const height = readU16LE(bytes, 28) & 0x3fff;
if (width < 1 || height < 1) return null;
return { width, height };
}
return null;
}
function rasterDimensions(
kind: CreateFlowUploadRasterKind,
bytes: Uint8Array,
): { width: number; height: number } | null {
switch (kind) {
case "png":
return pngDimensions(bytes);
case "gif":
return gifDimensions(bytes);
case "jpeg":
return jpegDimensions(bytes);
case "webp":
return webpDimensions(bytes);
}
}
function purposeAllowsKind(
purpose: CreateFlowUploadPurpose,
kind: CreateFlowUploadRasterKind | "pdf",
): boolean {
if (kind === "pdf") return purpose === "customMethodAttachment";
return true;
}
/**
* Size, true-type, SVG, and header-decode checks shared by the browser and
* `POST /api/uploads`. Call {@link validateCreateFlowUploadFile} on the client
* so oversized files are rejected before they are read into memory.
*/
export function validateCreateFlowUploadBytes(
purpose: CreateFlowUploadPurpose,
bytes: Uint8Array,
): CreateFlowUploadValidationResult {
if (bytes.length === 0) return { ok: false, reason: "empty" };
if (bytes.length > maxBytesForPurpose(purpose)) {
return { ok: false, reason: "tooLarge" };
}
const sniffed = sniffCreateFlowUploadBytes(bytes);
if (sniffed === "empty") return { ok: false, reason: "empty" };
if (sniffed === "svg") return { ok: false, reason: "svg" };
if (sniffed === "unknown") return { ok: false, reason: "invalidType" };
if (!purposeAllowsKind(purpose, sniffed)) {
return { ok: false, reason: "invalidType" };
}
if (sniffed === "pdf") {
return { ok: true, kind: "pdf", mimeType: mimeTypeForSniffedKind("pdf") };
}
if (rasterDimensions(sniffed, bytes) == null) {
return { ok: false, reason: "undecodable" };
}
return {
ok: true,
kind: sniffed,
mimeType: mimeTypeForSniffedKind(sniffed),
};
}
async function readBlobBytes(blob: Blob): Promise<Uint8Array> {
if (typeof blob.arrayBuffer === "function") {
return new Uint8Array(await blob.arrayBuffer());
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(new Uint8Array(reader.result as ArrayBuffer));
};
reader.onerror = () => {
reject(reader.error ?? new Error("FileReader failed"));
};
reader.readAsArrayBuffer(blob);
});
}
async function decodeRasterInBrowser(
bytes: Uint8Array,
mimeType: string,
): Promise<boolean> {
if (typeof createImageBitmap !== "function") return true;
try {
const copy = new Uint8Array(bytes);
const bitmap = await createImageBitmap(
new Blob([copy], { type: mimeType }),
);
const ok = bitmap.width > 0 && bitmap.height > 0;
bitmap.close();
return ok;
} catch {
return false;
}
}
/**
* Client-side validation: size and SVG name/type before reading bytes, then
* the same sniff/decode path as the server, plus `createImageBitmap` when
* available.
*/
export async function validateCreateFlowUploadFile(
file: File,
purpose: CreateFlowUploadPurpose,
): Promise<CreateFlowUploadValidationResult> {
if (file.size === 0) return { ok: false, reason: "empty" };
if (file.size > maxBytesForPurpose(purpose)) {
return { ok: false, reason: "tooLarge" };
}
if (fileLooksLikeSvg(file)) return { ok: false, reason: "svg" };
const bytes = await readBlobBytes(file);
const result = validateCreateFlowUploadBytes(purpose, bytes);
if (result.ok === false || result.kind === "pdf") return result;
const decoded = await decodeRasterInBrowser(bytes, result.mimeType);
if (!decoded) return { ok: false, reason: "undecodable" };
return result;
}
+15 -2
View File
@@ -22,7 +22,20 @@ function openDb(): Promise<IDBDatabase> {
});
}
function coerceToFile(value: unknown): File | null {
if (value instanceof File) return value;
if (typeof Blob !== "undefined" && value instanceof Blob) {
return new File([value], "community-avatar", {
type: value.type || "application/octet-stream",
});
}
return null;
}
export async function storePendingCommunityAvatarFile(file: File): Promise<void> {
if (typeof indexedDB === "undefined") {
throw new Error("indexedDB is not available");
}
const db = await openDb();
try {
await new Promise<void>((resolve, reject) => {
@@ -38,6 +51,7 @@ export async function storePendingCommunityAvatarFile(file: File): Promise<void>
/** Read staged file without removing it (caller clears after successful upload). */
export async function readPendingCommunityAvatarFile(): Promise<File | null> {
if (typeof indexedDB === "undefined") return null;
const db = await openDb();
try {
return await new Promise<File | null>((resolve, reject) => {
@@ -45,8 +59,7 @@ export async function readPendingCommunityAvatarFile(): Promise<File | null> {
tx.onerror = () => reject(tx.error ?? new Error("indexedDB read failed"));
const getReq = tx.objectStore(STORE).get(KEY);
getReq.onsuccess = () => {
const v = getReq.result;
resolve(v instanceof File ? v : null);
resolve(coerceToFile(getReq.result));
};
getReq.onerror = () => reject(getReq.error);
});
+62 -4
View File
@@ -1,4 +1,10 @@
import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose";
import {
CreateFlowUploadValidationError,
messageKeyForCreateFlowUploadReason,
validateCreateFlowUploadFile,
type CreateFlowUploadValidationReason,
} from "./createFlowUploadValidation";
export type UploadToServerResult = {
url: string;
@@ -7,6 +13,20 @@ export type UploadToServerResult = {
byteLength: number;
};
const VALIDATION_REASONS = new Set<CreateFlowUploadValidationReason>([
"empty",
"tooLarge",
"svg",
"invalidType",
"undecodable",
]);
function reasonFromUnknown(value: unknown): CreateFlowUploadValidationReason | null {
return typeof value === "string" && VALIDATION_REASONS.has(value as CreateFlowUploadValidationReason)
? (value as CreateFlowUploadValidationReason)
: null;
}
/**
* Authenticated multipart upload to `POST /api/uploads`.
* Caller must have a session cookie (same-origin fetch).
@@ -15,6 +35,11 @@ export async function uploadCreateFlowFile(
file: File,
purpose: CreateFlowUploadPurpose,
): Promise<UploadToServerResult> {
const validated = await validateCreateFlowUploadFile(file, purpose);
if (validated.ok === false) {
throw new CreateFlowUploadValidationError(validated.reason);
}
const formData = new FormData();
formData.append("purpose", purpose);
formData.append("file", file);
@@ -36,14 +61,24 @@ export async function uploadCreateFlowFile(
if (body && typeof body === "object" && "error" in body) {
const e = (body as {
error?: { message?: string; code?: string };
details?: { reason?: unknown };
}).error;
if (!e) return { message: null as string | null, code: null as string | null };
const details = (body as { details?: { reason?: unknown } }).details;
const reason = reasonFromUnknown(details?.reason);
if (!e) {
return {
message: null as string | null,
code: null as string | null,
reason,
};
}
return {
message: typeof e.message === "string" ? e.message : null,
code: typeof e.code === "string" ? e.code : null,
reason,
};
}
return { message: null, code: null };
return { message: null, code: null, reason: null };
})();
if (!res.ok) {
@@ -54,7 +89,7 @@ export async function uploadCreateFlowFile(
? "UNAUTHORIZED"
: "UPLOAD_FAILED";
const code = errParts.code ?? errParts.message ?? fallback;
throw new UploadToServerError(res.status, code);
throw new UploadToServerError(res.status, code, errParts.reason);
}
const data = body as {
@@ -83,11 +118,34 @@ export async function uploadCreateFlowFile(
export class UploadToServerError extends Error {
readonly status: number;
readonly code: string;
readonly reason: CreateFlowUploadValidationReason | null;
constructor(status: number, code: string) {
constructor(
status: number,
code: string,
reason: CreateFlowUploadValidationReason | null = null,
) {
super(code);
this.name = "UploadToServerError";
this.status = status;
this.code = code;
this.reason = reason;
}
}
export function createFlowUploadFailureMessageKey(err: unknown): string {
if (err instanceof CreateFlowUploadValidationError) {
return messageKeyForCreateFlowUploadReason(err.reason);
}
if (err instanceof UploadToServerError) {
if (err.reason) return messageKeyForCreateFlowUploadReason(err.reason);
if (err.status === 413) return "errors.tooLarge";
if (err.status === 401) return "errors.unauthorized";
if (err.code === "server_misconfigured") {
return "errors.misconfigured";
}
}
return "errors.generic";
}
export { CreateFlowUploadValidationError };