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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user