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('');
}