allowlist.ts4.4 KBView on GitHub

Introduced 1 production defect in 180 days, median 43 days to fix.

/**
 * Upload allowlist — frontend mirror of
 * apps/server/src/services/file-system/uploads/allowlist.ts.
 * Keep the two in sync.
 */

export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
export const MAX_FILES_PER_DROP = 10;

export interface AllowlistEntry {
  category: 'pdf' | 'word' | 'powerpoint' | 'spreadsheet' | 'markdown' | 'image' | 'svg' | 'html';
  extensions: string[];
  mimeTypes: string[];
}

export const UPLOAD_ALLOWLIST: AllowlistEntry[] = [
  {
    category: 'pdf',
    extensions: ['.pdf'],
    mimeTypes: ['application/pdf'],
  },
  {
    category: 'word',
    extensions: ['.doc', '.docx'],
    mimeTypes: [
      'application/msword',
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    ],
  },
  {
    category: 'powerpoint',
    extensions: ['.ppt', '.pptx'],
    mimeTypes: [
      'application/vnd.ms-powerpoint',
      'application/vnd.openxmlformats-officedocument.presentationml.presentation',
    ],
  },
  {
    category: 'spreadsheet',
    extensions: ['.xls', '.xlsx', '.csv'],
    mimeTypes: [
      'application/vnd.ms-excel',
      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
      'text/csv',
      'application/csv',
    ],
  },
  {
    category: 'markdown',
    extensions: ['.md', '.markdown'],
    mimeTypes: ['text/markdown', 'text/x-markdown', 'text/plain'],
  },
  {
    category: 'image',
    extensions: ['.png', '.jpg', '.jpeg', '.gif', '.webp'],
    mimeTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
  },
  {
    // SVG is image/* by mime type but treated as text downstream — server
    // extracts and inlines it into the chat prompt rather than passing a
    // presigned image URL. Kept in its own category so callers don't route
    // SVGs through the binary-image path. See server allowlist.ts.
    category: 'svg',
    extensions: ['.svg'],
    mimeTypes: ['image/svg+xml'],
  },
  {
    category: 'html',
    extensions: ['.html', '.htm'],
    mimeTypes: ['text/html'],
  },
];

export const ALLOWED_EXTENSIONS: ReadonlySet<string> = new Set(
  UPLOAD_ALLOWLIST.flatMap((e) => e.extensions),
);

export const ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set(
  UPLOAD_ALLOWLIST.flatMap((e) => e.mimeTypes),
);

export function extOf(filename: string): string {
  const i = filename.lastIndexOf('.');
  return i === -1 ? '' : filename.slice(i).toLowerCase();
}

export function categoryFor(mimeType: string, filename: string): AllowlistEntry['category'] | null {
  const lowerMime = mimeType.toLowerCase();
  const lowerExt = extOf(filename);
  for (const entry of UPLOAD_ALLOWLIST) {
    if (entry.mimeTypes.includes(lowerMime)) return entry.category;
    if (entry.extensions.includes(lowerExt)) return entry.category;
  }
  return null;
}

export type UploadRejection =
  | { ok: false; code: 'too_large'; message: string }
  | { ok: false; code: 'bad_type'; message: string }
  | { ok: false; code: 'empty'; message: string };

export type UploadAcceptance = {
  ok: true;
  category: AllowlistEntry['category'];
  ext: string;
  mimeType: string;
};

export function validateUpload(args: {
  filename: string;
  mimeType: string;
  sizeBytes: number;
}): UploadAcceptance | UploadRejection {
  if (args.sizeBytes <= 0) {
    return { ok: false, code: 'empty', message: 'File is empty' };
  }
  if (args.sizeBytes > MAX_UPLOAD_BYTES) {
    return {
      ok: false,
      code: 'too_large',
      message: `File exceeds ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit`,
    };
  }
  const category = categoryFor(args.mimeType, args.filename);
  if (!category) {
    return {
      ok: false,
      code: 'bad_type',
      message: 'Cedar accepts PDF, Word, PowerPoint, Excel/CSV, Markdown, HTML, and images.',
    };
  }
  return { ok: true, category, ext: extOf(args.filename), mimeType: args.mimeType.toLowerCase() };
}

/**
 * Returns the `accept` attribute for an `<input type="file">`.
 * Combines all extensions and MIME types from the allowlist.
 */
export function inputAcceptAttribute(): string {
  const ext = Array.from(ALLOWED_EXTENSIONS).join(',');
  const mime = Array.from(ALLOWED_MIME_TYPES).join(',');
  return `${ext},${mime}`;
}

/** Compute SHA-256 of a File using the Web Crypto API. */
export async function sha256OfFile(file: File): Promise<string> {
  const buf = await file.arrayBuffer();
  const digest = await crypto.subtle.digest('SHA-256', buf);
  return Array.from(new Uint8Array(digest))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}