feat: add MEDIA outbound attachment directive

This commit is contained in:
ejclaw
2026-05-26 17:26:20 +09:00
parent 89b3dd382e
commit df6ec5f2b2
11 changed files with 373 additions and 29 deletions

View File

@@ -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;
}