feat: add MEDIA outbound attachment directive
This commit is contained in:
@@ -18,6 +18,29 @@ export interface ValidateOutboundAttachmentsResult {
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
||||
const SUPPORTED_ATTACHMENT_MIMES: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.bmp': 'image/bmp',
|
||||
'.mp4': 'video/mp4',
|
||||
'.mov': 'video/quicktime',
|
||||
'.webm': 'video/webm',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.flac': 'audio/flac',
|
||||
'.pdf': 'application/pdf',
|
||||
'.zip': 'application/zip',
|
||||
'.txt': 'text/plain',
|
||||
'.md': 'text/markdown',
|
||||
'.csv': 'text/csv',
|
||||
'.json': 'application/json',
|
||||
};
|
||||
|
||||
function unique(values: Array<string | null | undefined>): string[] {
|
||||
return [
|
||||
...new Set(values.filter((value): value is string => Boolean(value))),
|
||||
@@ -127,6 +150,81 @@ function detectImageMime(filePath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function readHeader(filePath: string, byteCount = 512): Buffer {
|
||||
const handle = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(byteCount);
|
||||
const bytesRead = fs.readSync(handle, buffer, 0, buffer.length, 0);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
fs.closeSync(handle);
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyTextFile(header: Buffer): boolean {
|
||||
return !header.includes(0);
|
||||
}
|
||||
|
||||
function detectSupportedMime(filePath: string): string | null {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const expectedMime = SUPPORTED_ATTACHMENT_MIMES[ext];
|
||||
if (!expectedMime) return null;
|
||||
if (IMAGE_EXTS.test(filePath)) {
|
||||
return detectImageMime(filePath);
|
||||
}
|
||||
|
||||
const header = readHeader(filePath);
|
||||
switch (ext) {
|
||||
case '.mp4':
|
||||
case '.mov':
|
||||
case '.m4a':
|
||||
return header.subarray(4, 8).toString('ascii') === 'ftyp'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.webm':
|
||||
return header.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.mp3':
|
||||
return header.subarray(0, 3).toString('ascii') === 'ID3' ||
|
||||
(header[0] === 0xff && (header[1] & 0xe0) === 0xe0)
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.wav':
|
||||
return header.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
header.subarray(8, 12).toString('ascii') === 'WAVE'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.ogg':
|
||||
return header.subarray(0, 4).toString('ascii') === 'OggS'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.flac':
|
||||
return header.subarray(0, 4).toString('ascii') === 'fLaC'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.pdf':
|
||||
return header.subarray(0, 5).toString('ascii') === '%PDF-'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.zip':
|
||||
return header
|
||||
.subarray(0, 4)
|
||||
.equals(Buffer.from([0x50, 0x4b, 0x03, 0x04])) ||
|
||||
header.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x05, 0x06])) ||
|
||||
header.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x07, 0x08]))
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.txt':
|
||||
case '.md':
|
||||
case '.csv':
|
||||
case '.json':
|
||||
return isLikelyTextFile(header) ? expectedMime : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAttachmentName(
|
||||
attachment: OutboundAttachment,
|
||||
realPath: string,
|
||||
@@ -157,7 +255,9 @@ export function validateOutboundAttachments(
|
||||
rejected.push({ path: requestedPath, reason: 'not-absolute' });
|
||||
continue;
|
||||
}
|
||||
if (!IMAGE_EXTS.test(requestedPath)) {
|
||||
const expectedMime =
|
||||
SUPPORTED_ATTACHMENT_MIMES[path.extname(requestedPath).toLowerCase()];
|
||||
if (!expectedMime) {
|
||||
rejected.push({ path: requestedPath, reason: 'unsupported-extension' });
|
||||
continue;
|
||||
}
|
||||
@@ -183,11 +283,13 @@ export function validateOutboundAttachments(
|
||||
rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' });
|
||||
continue;
|
||||
}
|
||||
const detectedMime = detectImageMime(realPath);
|
||||
const detectedMime = detectSupportedMime(realPath);
|
||||
if (!detectedMime) {
|
||||
rejected.push({
|
||||
path: requestedPath,
|
||||
reason: 'invalid-image-signature',
|
||||
reason: IMAGE_EXTS.test(realPath)
|
||||
? 'invalid-image-signature'
|
||||
: 'invalid-file-signature',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user